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:
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- The **Markdown / Plain** toggle lets you switch between formatted markdown and literal/raw text rendering.
|
||||||
|
|||||||
@@ -42,6 +42,18 @@
|
|||||||
background: var(--text-muted);
|
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 {
|
.agent-log-model-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -360,6 +372,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@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 {
|
.agent-log-return-to-live {
|
||||||
right: var(--space-sm);
|
right: var(--space-sm);
|
||||||
bottom: var(--space-sm);
|
bottom: var(--space-sm);
|
||||||
|
|||||||
@@ -475,7 +475,7 @@ export function AgentLogViewer({
|
|||||||
if (loading && entries.length === 0) {
|
if (loading && entries.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="agent-log-viewer" data-testid="agent-log-viewer">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1737,7 +1737,24 @@
|
|||||||
box-shadow: var(--shadow-sm);
|
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) {
|
@media (max-width: 768px) {
|
||||||
|
.detail-log-loading {
|
||||||
|
min-height: calc(var(--space-2xl) + var(--space-lg));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.detail-tab {
|
.detail-tab {
|
||||||
min-height: 36px;
|
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 })}
|
{t("taskDetail.logs.truncated", "Showing the most recent {{count}} activity entries.", { count: workingTask.log.length })}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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}>
|
<div className="detail-activity-list" ref={activityListRef}>
|
||||||
{(() => {
|
{(() => {
|
||||||
let highlightedOnce = false;
|
let highlightedOnce = false;
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ describe("AgentLogViewer", () => {
|
|||||||
|
|
||||||
it("shows loading message when loading with no entries", () => {
|
it("shows loading message when loading with no entries", () => {
|
||||||
render(<AgentLogViewer entries={[]} loading={true} />);
|
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", () => {
|
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", () => {
|
describe("TaskDetailModal branch group surfacing", () => {
|
||||||
it("renders branch group card when task has group context", () => {
|
it("renders branch group card when task has group context", () => {
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -43,6 +43,16 @@ const originalEventSource = globalThis.EventSource;
|
|||||||
|
|
||||||
const INITIAL_LOAD_LIMIT = 100;
|
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(() => {
|
beforeEach(() => {
|
||||||
MockEventSource.instances = [];
|
MockEventSource.instances = [];
|
||||||
(globalThis as any).EventSource = MockEventSource;
|
(globalThis as any).EventSource = MockEventSource;
|
||||||
@@ -62,6 +72,26 @@ describe("useAgentLogs", () => {
|
|||||||
expect(result.current.entries).toEqual([]);
|
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 () => {
|
it("fetches historical logs and opens SSE when enabled=true", async () => {
|
||||||
const historicalLogs = [
|
const historicalLogs = [
|
||||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "old", type: "text" as const },
|
{ 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 () => {
|
it("clears entries immediately when projectId changes", async () => {
|
||||||
// Set up mock to return different values based on projectId
|
// Set up mock to return different values based on projectId
|
||||||
mockFetchAgentLogsWithMeta.mockImplementation((_taskId: string, projectId?: string) => {
|
mockFetchAgentLogsWithMeta.mockImplementation((_taskId: string, projectId?: string) => {
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
|||||||
|
|
||||||
const INITIAL_LOAD_LIMIT = 100;
|
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.
|
* 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 [hasMore, setHasMore] = useState(false);
|
||||||
const [total, setTotal] = useState<number | null>(null);
|
const [total, setTotal] = useState<number | null>(null);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [loadedContextKey, setLoadedContextKey] = useState<string | null>(null);
|
||||||
|
|
||||||
// Refs for state that needs to survive re-renders
|
// Refs for state that needs to survive re-renders
|
||||||
const unsubscribeRef = useRef<(() => void) | null>(null);
|
const unsubscribeRef = useRef<(() => void) | null>(null);
|
||||||
@@ -54,6 +60,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
|
|||||||
const requestVersionRef = useRef(0);
|
const requestVersionRef = useRef(0);
|
||||||
|
|
||||||
// Detect context changes and clear state immediately
|
// Detect context changes and clear state immediately
|
||||||
|
const activeContextKey = getActiveContextKey(taskId, enabled, projectId);
|
||||||
const contextChanged =
|
const contextChanged =
|
||||||
previousTaskIdRef.current !== taskId ||
|
previousTaskIdRef.current !== taskId ||
|
||||||
previousProjectIdRef.current !== projectId ||
|
previousProjectIdRef.current !== projectId ||
|
||||||
@@ -80,6 +87,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
|
|||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
setTotal(null);
|
setTotal(null);
|
||||||
setLoadingMore(false);
|
setLoadingMore(false);
|
||||||
|
setLoadedContextKey(null);
|
||||||
|
|
||||||
// Drop existing SSE subscription
|
// Drop existing SSE subscription
|
||||||
if (unsubscribeRef.current) {
|
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
|
// Capture taskId and projectId at effect start for comparison
|
||||||
const currentTaskId = taskId;
|
const currentTaskId = taskId;
|
||||||
const currentProjectId = projectId;
|
const currentProjectId = projectId;
|
||||||
|
const requestContextKey = getActiveContextKey(currentTaskId, true, currentProjectId);
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
if (!currentTaskId) return;
|
if (!currentTaskId) return;
|
||||||
@@ -124,6 +133,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
|
|||||||
setEntries(result.entries);
|
setEntries(result.entries);
|
||||||
setHasMore(result.hasMore);
|
setHasMore(result.hasMore);
|
||||||
setTotal(result.total);
|
setTotal(result.total);
|
||||||
|
setLoadedContextKey(requestContextKey);
|
||||||
} catch {
|
} catch {
|
||||||
// Reject stale error: check context version and request version
|
// Reject stale error: check context version and request version
|
||||||
if (cancelledRef.current ||
|
if (cancelledRef.current ||
|
||||||
@@ -134,6 +144,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
|
|||||||
setEntries([]);
|
setEntries([]);
|
||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
setTotal(null);
|
setTotal(null);
|
||||||
|
setLoadedContextKey(requestContextKey);
|
||||||
} finally {
|
} finally {
|
||||||
// Only update loading state if not cancelled and not stale
|
// Only update loading state if not cancelled and not stale
|
||||||
if (!cancelledRef.current &&
|
if (!cancelledRef.current &&
|
||||||
@@ -234,6 +245,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
|
|||||||
}, [taskId, projectId, entries.length, loadingMore]);
|
}, [taskId, projectId, entries.length, loadingMore]);
|
||||||
|
|
||||||
const clear = useCallback(() => setEntries([]), []);
|
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