diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 41497bfd16..eb50ffc2da 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. diff --git a/packages/dashboard/app/components/AgentLogViewer.css b/packages/dashboard/app/components/AgentLogViewer.css index 3e96623423..90ec4b3047 100644 --- a/packages/dashboard/app/components/AgentLogViewer.css +++ b/packages/dashboard/app/components/AgentLogViewer.css @@ -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); diff --git a/packages/dashboard/app/components/AgentLogViewer.tsx b/packages/dashboard/app/components/AgentLogViewer.tsx index 9adc7b9bc7..bb37865633 100644 --- a/packages/dashboard/app/components/AgentLogViewer.tsx +++ b/packages/dashboard/app/components/AgentLogViewer.tsx @@ -475,7 +475,7 @@ export function AgentLogViewer({ if (loading && entries.length === 0) { return (
-
{t("agentLog.loading", "Loading agent logs…")}
+
{t("agentLog.loading", "Loading agent logs…")}
); } diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css index 85dfe64467..03fc24c8e4 100644 --- a/packages/dashboard/app/components/TaskDetailModal.css +++ b/packages/dashboard/app/components/TaskDetailModal.css @@ -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; } diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 6c0bfd7806..d2818f4d6b 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -3102,7 +3102,12 @@ export function TaskDetailContent({ {t("taskDetail.logs.truncated", "Showing the most recent {{count}} activity entries.", { count: workingTask.log.length })} ) : null} - {workingTask.log && workingTask.log.length > 0 ? ( + {detailLoading ? ( +
+
+ ) : workingTask.log && workingTask.log.length > 0 ? (
{(() => { let highlightedOnce = false; diff --git a/packages/dashboard/app/components/__tests__/AgentLogViewer.test.tsx b/packages/dashboard/app/components/__tests__/AgentLogViewer.test.tsx index a86a87d5cb..64245f4620 100644 --- a/packages/dashboard/app/components/__tests__/AgentLogViewer.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentLogViewer.test.tsx @@ -37,7 +37,8 @@ describe("AgentLogViewer", () => { it("shows loading message when loading with no entries", () => { render(); - 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", () => { diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx index bb0a0fa8a6..7a16ce2308 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx @@ -92,6 +92,150 @@ describe("TaskDetailModal GitHub tracking CTA", () => { }); }); +describe("TaskDetailModal Logs activity loading", () => { + function renderLogsModal(task: ReturnType | Record) { + return render( + , + ); + } + + function makeSlimTask(overrides: Record = {}) { + 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( + , + ); + + 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( + , + ); + + 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( diff --git a/packages/dashboard/app/hooks/__tests__/useAgentLogs.test.ts b/packages/dashboard/app/hooks/__tests__/useAgentLogs.test.ts index d25615b083..2c7e5d243a 100644 --- a/packages/dashboard/app/hooks/__tests__/useAgentLogs.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useAgentLogs.test.ts @@ -43,6 +43,16 @@ const originalEventSource = globalThis.EventSource; const INITIAL_LOAD_LIMIT = 100; +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((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) => { diff --git a/packages/dashboard/app/hooks/useAgentLogs.ts b/packages/dashboard/app/hooks/useAgentLogs.ts index 39904db601..4b97244672 100644 --- a/packages/dashboard/app/hooks/useAgentLogs.ts +++ b/packages/dashboard/app/hooks/useAgentLogs.ts @@ -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(null); const [loadingMore, setLoadingMore] = useState(false); + const [loadedContextKey, setLoadedContextKey] = useState(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 }; }