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
This commit is contained in:
@@ -42,6 +42,18 @@
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.agent-log-loading,
|
||||
.agent-log-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
min-height: calc(var(--space-2xl) + var(--space-lg));
|
||||
padding: var(--space-lg);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.agent-log-model-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -360,6 +372,12 @@
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.agent-log-loading,
|
||||
.agent-log-empty {
|
||||
align-items: flex-start;
|
||||
min-height: calc(var(--space-2xl) + var(--space-xl));
|
||||
}
|
||||
|
||||
.agent-log-return-to-live {
|
||||
right: var(--space-sm);
|
||||
bottom: var(--space-sm);
|
||||
|
||||
@@ -475,7 +475,7 @@ export function AgentLogViewer({
|
||||
if (loading && entries.length === 0) {
|
||||
return (
|
||||
<div className="agent-log-viewer" data-testid="agent-log-viewer">
|
||||
<div className="agent-log-loading">{t("agentLog.loading", "Loading agent logs…")}</div>
|
||||
<div className="agent-log-loading" role="status" aria-live="polite">{t("agentLog.loading", "Loading agent logs…")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1737,7 +1737,24 @@
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.detail-log-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
color: var(--text-muted);
|
||||
min-height: calc(var(--space-2xl) + var(--space-md));
|
||||
}
|
||||
|
||||
.detail-log-loading svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.detail-log-loading {
|
||||
min-height: calc(var(--space-2xl) + var(--space-lg));
|
||||
}
|
||||
|
||||
|
||||
.detail-tab {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
@@ -3102,7 +3102,12 @@ export function TaskDetailContent({
|
||||
{t("taskDetail.logs.truncated", "Showing the most recent {{count}} activity entries.", { count: workingTask.log.length })}
|
||||
</div>
|
||||
) : null}
|
||||
{workingTask.log && workingTask.log.length > 0 ? (
|
||||
{detailLoading ? (
|
||||
<div className="detail-log-loading" role="status" aria-live="polite">
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
<span>{t("taskDetail.logs.loadingActivity", "Loading activity…")}</span>
|
||||
</div>
|
||||
) : workingTask.log && workingTask.log.length > 0 ? (
|
||||
<div className="detail-activity-list" ref={activityListRef}>
|
||||
{(() => {
|
||||
let highlightedOnce = false;
|
||||
|
||||
@@ -37,7 +37,8 @@ describe("AgentLogViewer", () => {
|
||||
|
||||
it("shows loading message when loading with no entries", () => {
|
||||
render(<AgentLogViewer entries={[]} loading={true} />);
|
||||
expect(screen.getByText("Loading agent logs…")).toBeTruthy();
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Loading agent logs…");
|
||||
expect(screen.queryByText("No agent output yet.")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows empty message when no entries and not loading", () => {
|
||||
|
||||
@@ -92,6 +92,150 @@ describe("TaskDetailModal GitHub tracking CTA", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskDetailModal Logs activity loading", () => {
|
||||
function renderLogsModal(task: ReturnType<typeof makeTask> | Record<string, unknown>) {
|
||||
return render(
|
||||
<TaskDetailModal
|
||||
task={task as any}
|
||||
initialTab="logs"
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function makeSlimTask(overrides: Record<string, unknown> = {}) {
|
||||
const { prompt: _prompt, log: _log, steps: _steps, ...task } = makeTask({
|
||||
id: "FN-6040",
|
||||
description: "Slim task",
|
||||
...overrides,
|
||||
});
|
||||
return task;
|
||||
}
|
||||
|
||||
it("shows activity loading instead of empty state while slim task detail is pending", async () => {
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
vi.mocked(fetchTaskDetail).mockReset();
|
||||
vi.mocked(fetchTaskDetail).mockImplementationOnce(() => new Promise(() => {}));
|
||||
|
||||
renderLogsModal(makeSlimTask());
|
||||
|
||||
expect(await screen.findByRole("status")).toHaveTextContent("Loading activity…");
|
||||
expect(screen.queryByText("(no activity)")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows activity loading when switching to Logs before slim task detail resolves", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
vi.mocked(fetchTaskDetail).mockReset();
|
||||
vi.mocked(fetchTaskDetail).mockImplementationOnce(() => new Promise(() => {}));
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeSlimTask() as any}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Logs" }));
|
||||
expect(await screen.findByRole("status")).toHaveTextContent("Loading activity…");
|
||||
expect(screen.queryByText("(no activity)")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty activity only after loaded detail has no entries", async () => {
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
vi.mocked(fetchTaskDetail).mockReset();
|
||||
vi.mocked(fetchTaskDetail).mockResolvedValueOnce(makeTask({ id: "FN-6040", prompt: "# Loaded", log: [] }));
|
||||
|
||||
renderLogsModal(makeSlimTask());
|
||||
|
||||
expect(await screen.findByText("(no activity)")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders loaded activity entries newest first", async () => {
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
vi.mocked(fetchTaskDetail).mockReset();
|
||||
vi.mocked(fetchTaskDetail).mockResolvedValueOnce(makeTask({
|
||||
id: "FN-6040",
|
||||
prompt: "# Loaded",
|
||||
log: [
|
||||
{ timestamp: "2026-06-08T00:00:00.000Z", action: "older entry" },
|
||||
{ timestamp: "2026-06-08T00:01:00.000Z", action: "newer entry" },
|
||||
],
|
||||
}));
|
||||
|
||||
const { container } = renderLogsModal(makeSlimTask());
|
||||
|
||||
await screen.findByText("newer entry");
|
||||
const actions = Array.from(container.querySelectorAll(".detail-log-action")).map((node) => node.textContent);
|
||||
expect(actions).toEqual(["newer entry", "older entry"]);
|
||||
expect(screen.queryByText("(no activity)")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preserves truncated activity message after detail load", async () => {
|
||||
const { fetchTaskDetail } = await import("../../api");
|
||||
vi.mocked(fetchTaskDetail).mockReset();
|
||||
vi.mocked(fetchTaskDetail).mockResolvedValueOnce(makeTask({
|
||||
id: "FN-6040",
|
||||
prompt: "# Loaded",
|
||||
log: [{ timestamp: "2026-06-08T00:00:00.000Z", action: "kept entry" }],
|
||||
activityLogTruncatedCount: 25,
|
||||
} as any));
|
||||
|
||||
renderLogsModal(makeSlimTask());
|
||||
|
||||
expect(await screen.findByText("Showing the most recent 1 activity entries.")).toBeInTheDocument();
|
||||
expect(screen.getByText("kept entry")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskDetailModal Logs agent loading", () => {
|
||||
it("shows the Agent Log loading indicator when entering the subview", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
|
||||
const mockUseAgentLogs = vi.mocked(useAgentLogs);
|
||||
mockUseAgentLogs.mockImplementation((_taskId, enabled) => ({
|
||||
entries: [],
|
||||
loading: enabled,
|
||||
clear: vi.fn(),
|
||||
loadMore: vi.fn(async () => {}),
|
||||
hasMore: false,
|
||||
total: null,
|
||||
loadingMore: false,
|
||||
}));
|
||||
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ prompt: "# Loaded" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Logs" }));
|
||||
await user.click(screen.getByRole("button", { name: "Agent Log" }));
|
||||
|
||||
expect(screen.getByText("Loading agent logs…")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No agent output yet.")).not.toBeInTheDocument();
|
||||
|
||||
mockUseAgentLogs.mockImplementation(() => ({ entries: [], loading: false, clear: vi.fn(), loadMore: vi.fn(async () => {}), hasMore: false, total: null, loadingMore: false }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskDetailModal branch group surfacing", () => {
|
||||
it("renders branch group card when task has group context", () => {
|
||||
render(
|
||||
|
||||
@@ -43,6 +43,16 @@ const originalEventSource = globalThis.EventSource;
|
||||
|
||||
const INITIAL_LOAD_LIMIT = 100;
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
MockEventSource.instances = [];
|
||||
(globalThis as any).EventSource = MockEventSource;
|
||||
@@ -62,6 +72,26 @@ describe("useAgentLogs", () => {
|
||||
expect(result.current.entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports loading immediately when enabled until initial history fetch completes", async () => {
|
||||
const deferred = createDeferred<{ entries: []; total: number; hasMore: boolean }>();
|
||||
mockFetchAgentLogsWithMeta.mockReturnValueOnce(deferred.promise);
|
||||
|
||||
const { result } = renderHook(() => useAgentLogs("FN-001", true));
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.entries).toEqual([]);
|
||||
|
||||
await act(async () => {
|
||||
deferred.resolve({ entries: [], total: 0, hasMore: false });
|
||||
await deferred.promise;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
expect(result.current.entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("fetches historical logs and opens SSE when enabled=true", async () => {
|
||||
const historicalLogs = [
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "old", type: "text" as const },
|
||||
@@ -481,6 +511,50 @@ describe("useAgentLogs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("clears entries and reports loading immediately when projectId changes", async () => {
|
||||
const secondFetch = createDeferred<{
|
||||
entries: Array<{ timestamp: string; taskId: string; text: string; type: "text" }>;
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
}>();
|
||||
mockFetchAgentLogsWithMeta
|
||||
.mockResolvedValueOnce({
|
||||
entries: [{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "proj-A-log", type: "text" as const }],
|
||||
total: 1,
|
||||
hasMore: false,
|
||||
})
|
||||
.mockReturnValueOnce(secondFetch.promise);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ projectId }) => useAgentLogs("FN-001", true, projectId),
|
||||
{ initialProps: { projectId: "proj-A" } },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries.map((entry) => entry.text)).toEqual(["proj-A-log"]);
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
rerender({ projectId: "proj-B" });
|
||||
|
||||
expect(result.current.entries).toEqual([]);
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
secondFetch.resolve({
|
||||
entries: [{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "proj-B-log", type: "text" as const }],
|
||||
total: 1,
|
||||
hasMore: false,
|
||||
});
|
||||
await secondFetch.promise;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.entries.map((entry) => entry.text)).toEqual(["proj-B-log"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("clears entries immediately when projectId changes", async () => {
|
||||
// Set up mock to return different values based on projectId
|
||||
mockFetchAgentLogsWithMeta.mockImplementation((_taskId: string, projectId?: string) => {
|
||||
|
||||
@@ -6,6 +6,11 @@ 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.
|
||||
*
|
||||
@@ -36,6 +41,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
|
||||
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);
|
||||
@@ -54,6 +60,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
|
||||
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 ||
|
||||
@@ -80,6 +87,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
|
||||
setHasMore(false);
|
||||
setTotal(null);
|
||||
setLoadingMore(false);
|
||||
setLoadedContextKey(null);
|
||||
|
||||
// Drop existing SSE subscription
|
||||
if (unsubscribeRef.current) {
|
||||
@@ -106,6 +114,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
|
||||
// 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;
|
||||
@@ -124,6 +133,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
|
||||
setEntries(result.entries);
|
||||
setHasMore(result.hasMore);
|
||||
setTotal(result.total);
|
||||
setLoadedContextKey(requestContextKey);
|
||||
} catch {
|
||||
// Reject stale error: check context version and request version
|
||||
if (cancelledRef.current ||
|
||||
@@ -134,6 +144,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
|
||||
setEntries([]);
|
||||
setHasMore(false);
|
||||
setTotal(null);
|
||||
setLoadedContextKey(requestContextKey);
|
||||
} finally {
|
||||
// Only update loading state if not cancelled and not stale
|
||||
if (!cancelledRef.current &&
|
||||
@@ -234,6 +245,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
|
||||
}, [taskId, projectId, entries.length, loadingMore]);
|
||||
|
||||
const clear = useCallback(() => setEntries([]), []);
|
||||
const initialContextLoading = Boolean(activeContextKey && loadedContextKey !== activeContextKey);
|
||||
|
||||
return { entries, loading, clear, loadMore, hasMore, total, loadingMore };
|
||||
return { entries, loading: loading || initialContextLoading, clear, loadMore, hasMore, total, loadingMore };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user