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:
gsxdsm
2026-06-08 13:21:50 -07:00
parent 474b492b26
commit 6c129dafce
9 changed files with 276 additions and 4 deletions

View File

@@ -661,6 +661,7 @@ The **Logs** tab includes an **Agent Log** subview designed for debugging long-r
- Full `thinking`, `tool_result`, and `tool_error` payloads are shown without entry-content truncation.
- Raw tool output is rendered as multiline blocks, preserving line breaks and indentation.
- The Activity and Agent Log subviews show loading indicators while their first async history/detail request is pending, so empty states only appear after the relevant fetch completes.
- The initial load fetches a recent page, then **Load More** progressively prepends older history.
- Live streaming appends new entries in chronological order while preserving your scroll position when loading older pages.
- The **Markdown / Plain** toggle lets you switch between formatted markdown and literal/raw text rendering.

View File

@@ -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);

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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", () => {

View File

@@ -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(

View File

@@ -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) => {

View File

@@ -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 };
}