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 ? (
+
+
+ {t("taskDetail.logs.loadingActivity", "Loading activity…")}
+
+ ) : 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 };
}