Files
fusion/packages/dashboard/app/hooks/useAgentLogs.ts
gsxdsm 6c129dafce FN-6040: show loading indicators for task detail activity and agent logs
Add first-load loading states so task activity and agent log views do not flash empty states.

- show a loading status row in the task detail Activity tab while detail history is still loading
- keep agent logs in a loading state until the active task/project context has completed its initial fetch
- add accessible loading markup, regression tests, and dashboard guide documentation for the new behavior

Files changed:
 docs/dashboard-guide.md                            |   1 +
 .../dashboard/app/components/AgentLogViewer.css    |  18 +++
 .../dashboard/app/components/AgentLogViewer.tsx    |   2 +-
 .../dashboard/app/components/TaskDetailModal.css   |  17 +++
 .../dashboard/app/components/TaskDetailModal.tsx   |   7 +-
 .../components/__tests__/AgentLogViewer.test.tsx   |   3 +-
 .../components/__tests__/TaskDetailModal.test.tsx  | 144 +++++++++++++++++++++
 .../app/hooks/__tests__/useAgentLogs.test.ts       |  74 +++++++++++
 packages/dashboard/app/hooks/useAgentLogs.ts       |  14 +-
 9 files changed, 276 insertions(+), 4 deletions(-)

Fusion-Task-Id: FN-6040

Fusion-Task-Lineage: cd4b8f7f-63cd-4d75-be00-d362e915f4e8
2026-06-08 13:32:58 -07:00

252 lines
8.9 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from "react";
import type { AgentLogEntry } from "@fusion/core";
import { fetchAgentLogsWithMeta } from "../api";
import { subscribeSse } from "../sse-bus";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
const INITIAL_LOAD_LIMIT = 100;
function getActiveContextKey(taskId: string | null, enabled: boolean, projectId?: string): string | null {
if (!taskId || !enabled) return null;
return `${projectId ?? ""}\u0000${taskId}`;
}
/**
* Hook that manages agent log fetching and live SSE streaming for a task.
*
* 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 entries in chronological order (oldest first)
* - `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=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);
const [loadedContextKey, setLoadedContextKey] = useState<string | null>(null);
// Refs for state that needs to survive re-renders
const unsubscribeRef = useRef<(() => void) | null>(null);
const cancelledRef = useRef(false);
// Track the project context version to detect stale SSE events after project switches.
// Incremented whenever projectId changes, invalidating any in-flight SSE handlers.
const projectContextVersionRef = useRef(0);
// Track previous values to detect context changes
const previousTaskIdRef = useRef<string | null>(taskId);
const previousProjectIdRef = useRef<string | undefined>(projectId);
const previousEnabledRef = useRef(enabled);
// Track request version to reject stale fetch completions
const requestVersionRef = useRef(0);
// Detect context changes and clear state immediately
const activeContextKey = getActiveContextKey(taskId, enabled, projectId);
const contextChanged =
previousTaskIdRef.current !== taskId ||
previousProjectIdRef.current !== projectId ||
previousEnabledRef.current !== enabled;
if (contextChanged) {
previousTaskIdRef.current = taskId;
previousProjectIdRef.current = projectId;
previousEnabledRef.current = enabled;
projectContextVersionRef.current++;
recordResumeEvent({
view: "useAgentLogs",
trigger: "project-context-change",
projectId,
replayAttempted: false,
reason: "context-version-bumped",
detail: { taskId },
});
cancelledRef.current = true;
// Clear entries immediately on context change to prevent stale data visibility
setEntries([]);
setLoading(false);
setHasMore(false);
setTotal(null);
setLoadingMore(false);
setLoadedContextKey(null);
// Drop existing SSE subscription
if (unsubscribeRef.current) {
unsubscribeRef.current();
unsubscribeRef.current = null;
}
}
useEffect(() => {
if (!taskId || !enabled) {
// Drop any existing subscription when disabled
if (unsubscribeRef.current) {
unsubscribeRef.current();
unsubscribeRef.current = null;
}
return;
}
// Capture context version at effect start - stale SSE events will be rejected
const contextVersionAtStart = projectContextVersionRef.current;
const requestVersion = ++requestVersionRef.current;
cancelledRef.current = false;
// Capture taskId and projectId at effect start for comparison
const currentTaskId = taskId;
const currentProjectId = projectId;
const requestContextKey = getActiveContextKey(currentTaskId, true, currentProjectId);
async function init() {
if (!currentTaskId) return;
setLoading(true);
setLoadingMore(false);
try {
const result = await fetchAgentLogsWithMeta(currentTaskId, currentProjectId, { limit: INITIAL_LOAD_LIMIT });
// Reject stale response: check context version and request version
if (cancelledRef.current ||
projectContextVersionRef.current !== contextVersionAtStart ||
requestVersionRef.current !== requestVersion) {
return;
}
setEntries(result.entries);
setHasMore(result.hasMore);
setTotal(result.total);
setLoadedContextKey(requestContextKey);
} catch {
// Reject stale error: check context version and request version
if (cancelledRef.current ||
projectContextVersionRef.current !== contextVersionAtStart ||
requestVersionRef.current !== requestVersion) {
return;
}
setEntries([]);
setHasMore(false);
setTotal(null);
setLoadedContextKey(requestContextKey);
} finally {
// Only update loading state if not cancelled and not stale
if (!cancelledRef.current &&
projectContextVersionRef.current === contextVersionAtStart &&
requestVersionRef.current === requestVersion) {
setLoading(false);
}
}
// Subscribe to the shared per-task log stream
const query = currentProjectId ? `?projectId=${encodeURIComponent(currentProjectId)}` : "";
unsubscribeRef.current = subscribeSse(
`/api/tasks/${currentTaskId}/logs/stream${query}`,
{
onOpen: () => {
recordResumeEvent({
view: "useAgentLogs",
trigger: "sse-open",
projectId: currentProjectId,
replayAttempted: false,
sseChannel: `/api/tasks/${currentTaskId}/logs/stream`,
detail: { taskId: currentTaskId },
});
},
onReconnect: () => {
recordResumeEvent({
view: "useAgentLogs",
trigger: "sse-reconnect",
projectId: currentProjectId,
replayAttempted: false,
sseChannel: `/api/tasks/${currentTaskId}/logs/stream`,
detail: { taskId: currentTaskId },
});
},
events: {
"agent:log": (e) => {
if (cancelledRef.current ||
projectContextVersionRef.current !== contextVersionAtStart) {
return;
}
try {
const entry: AgentLogEntry = JSON.parse(e.data);
setEntries((prev) => [...prev, entry]);
setTotal((prev) => (prev !== null ? prev + 1 : null));
} catch {
// skip malformed events
}
},
},
},
);
}
void init();
return () => {
cancelledRef.current = true;
if (unsubscribeRef.current) {
unsubscribeRef.current();
unsubscribeRef.current = null;
}
};
}, [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) => [...result.entries, ...prev]);
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([]), []);
const initialContextLoading = Boolean(activeContextKey && loadedContextKey !== activeContextKey);
return { entries, loading: loading || initialContextLoading, clear, loadMore, hasMore, total, loadingMore };
}