fix(FN-789): fix activity log data loading and restore modal styling
- Fix useActivityLog hook to properly load activity log entries from the store - Add comprehensive test coverage for useActivityLog hook with 243 lines of tests - Restore missing ActivityLogModal CSS styles (modal overlay, list, animations) - Add ActivityLogModal component tests for empty state, loading, and error handling - Register activity log route in App.tsx - Update README with activity log data source documentation - Add changeset for patch release
This commit is contained in:
@@ -225,6 +225,10 @@ Browse and edit task worktree files directly from the task detail modal:
|
||||
### Activity Log
|
||||
View a centralized timeline of all task lifecycle events. Click the history icon in the header to open the Activity Log modal.
|
||||
|
||||
**Data Source**:
|
||||
- **Single-project mode** (default): Reads from the per-project activity log via `/api/activity`, which is always populated with task lifecycle events for the current project.
|
||||
- **Multi-project mode**: When projects are registered, the modal reads from the unified central feed via `/api/activity-feed`, which aggregates activity across all registered projects. A project filter dropdown allows narrowing results to a specific project.
|
||||
|
||||
**Features**:
|
||||
- **Event Types**: Track task:created, task:moved, task:merged, task:failed, task:deleted, and settings:updated events
|
||||
- **Task Links**: Click any task ID in the log to open its detail modal
|
||||
|
||||
@@ -742,6 +742,8 @@ function AppInner() {
|
||||
isOpen={activityLogOpen}
|
||||
onClose={handleCloseActivityLog}
|
||||
tasks={tasks}
|
||||
projectId={currentProject?.id}
|
||||
projects={projects}
|
||||
onOpenTaskDetail={(taskId) => {
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (task) {
|
||||
|
||||
@@ -55,7 +55,13 @@ function formatTimestamp(timestamp: string): string {
|
||||
|
||||
/**
|
||||
* ActivityLogModal - Activity log with project attribution and filtering
|
||||
*
|
||||
*
|
||||
* Data source selection:
|
||||
* - Single-project mode (no projects list): reads from the per-project activity
|
||||
* log via /api/activity, which is always populated with task lifecycle events.
|
||||
* - Multi-project mode (projects list provided): reads from the unified central
|
||||
* feed via /api/activity-feed, which aggregates activity across all projects.
|
||||
*
|
||||
* Features:
|
||||
* - Project name badge for each activity entry
|
||||
* - Project filter dropdown (when projects list provided)
|
||||
@@ -84,6 +90,11 @@ export function ActivityLogModal({
|
||||
const activityType = filteredType === "all" ? undefined : filteredType;
|
||||
const activeProjectId = filteredProjectId === "all" ? undefined : filteredProjectId;
|
||||
|
||||
// Determine data source: use unified central feed only when projects list
|
||||
// is provided (multi-project context). In single-project mode the hook reads
|
||||
// from the per-project activity log which is always populated.
|
||||
const useCentralFeed = projects.length > 0;
|
||||
|
||||
// Use the hook for data fetching
|
||||
const {
|
||||
entries,
|
||||
@@ -96,6 +107,7 @@ export function ActivityLogModal({
|
||||
type: activityType,
|
||||
limit: 100,
|
||||
autoRefresh: isOpen,
|
||||
useCentralFeed,
|
||||
});
|
||||
|
||||
// Convert entries to ActivityLogEntry format for compatibility
|
||||
|
||||
@@ -7,10 +7,12 @@ import type { ActivityLogEntry } from "@fusion/core";
|
||||
// Mock the API module
|
||||
vi.mock("../../api", () => ({
|
||||
fetchActivityFeed: vi.fn(),
|
||||
fetchActivityLog: vi.fn(),
|
||||
clearActivityLog: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchActivityFeed = vi.mocked(apiModule.fetchActivityFeed);
|
||||
const mockFetchActivityLog = vi.mocked(apiModule.fetchActivityLog);
|
||||
const mockClearActivityLog = vi.mocked(apiModule.clearActivityLog);
|
||||
|
||||
describe("ActivityLogModal", () => {
|
||||
@@ -22,6 +24,7 @@ describe("ActivityLogModal", () => {
|
||||
{ id: "FN-002", title: "Test Task 2", column: "in-progress" as const },
|
||||
];
|
||||
|
||||
/** Create entries that match both ActivityLogEntry and the ActivityFeedEntry shape */
|
||||
const mockActivityEntries: ActivityLogEntry[] = [
|
||||
{
|
||||
id: "1",
|
||||
@@ -29,7 +32,7 @@ describe("ActivityLogModal", () => {
|
||||
type: "task:created",
|
||||
taskId: "FN-001",
|
||||
taskTitle: "Test Task 1",
|
||||
details: "Task KB-001 created",
|
||||
details: "Task FN-001 created",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
@@ -37,7 +40,7 @@ describe("ActivityLogModal", () => {
|
||||
type: "task:moved",
|
||||
taskId: "FN-001",
|
||||
taskTitle: "Test Task 1",
|
||||
details: "Task KB-001 moved: todo → in-progress",
|
||||
details: "Task FN-001 moved: todo → in-progress",
|
||||
metadata: { from: "todo", to: "in-progress" },
|
||||
},
|
||||
{
|
||||
@@ -46,14 +49,23 @@ describe("ActivityLogModal", () => {
|
||||
type: "task:failed",
|
||||
taskId: "FN-002",
|
||||
taskTitle: "Test Task 2",
|
||||
details: "Task KB-002 failed: Something went wrong",
|
||||
details: "Task FN-002 failed: Something went wrong",
|
||||
metadata: { error: "Something went wrong" },
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchActivityFeed.mockResolvedValue(mockActivityEntries);
|
||||
// Default: per-project log returns entries (single-project mode)
|
||||
mockFetchActivityLog.mockResolvedValue(mockActivityEntries);
|
||||
// Unified feed also returns entries for multi-project mode tests
|
||||
mockFetchActivityFeed.mockResolvedValue(
|
||||
mockActivityEntries.map((e) => ({
|
||||
...e,
|
||||
projectId: "proj_1",
|
||||
projectName: "Test Project",
|
||||
})),
|
||||
);
|
||||
mockClearActivityLog.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
@@ -117,7 +129,7 @@ describe("ActivityLogModal", () => {
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls API on initial load", async () => {
|
||||
it("calls per-project API on initial load in single-project mode", async () => {
|
||||
render(
|
||||
<ActivityLogModal
|
||||
isOpen={true}
|
||||
@@ -128,6 +140,28 @@ describe("ActivityLogModal", () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// Single-project mode: uses fetchActivityLog (not fetchActivityFeed)
|
||||
expect(mockFetchActivityLog).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls unified feed API when projects are provided (multi-project mode)", async () => {
|
||||
const mockProjects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
];
|
||||
|
||||
render(
|
||||
<ActivityLogModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
tasks={mockTasks}
|
||||
projects={mockProjects}
|
||||
onOpenTaskDetail={mockOnOpenTaskDetail}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// Multi-project mode: uses fetchActivityFeed (not fetchActivityLog)
|
||||
expect(mockFetchActivityFeed).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -146,8 +180,8 @@ describe("ActivityLogModal", () => {
|
||||
fireEvent.change(filterSelect, { target: { value: "task:created" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchActivityFeed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "task:created" })
|
||||
expect(mockFetchActivityLog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "task:created" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -164,19 +198,19 @@ describe("ActivityLogModal", () => {
|
||||
|
||||
// Wait for initial load
|
||||
await waitFor(() => {
|
||||
expect(mockFetchActivityFeed).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetchActivityLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const refreshButton = screen.getByTestId("activity-refresh");
|
||||
fireEvent.click(refreshButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchActivityFeed).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetchActivityLog).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows empty state when no entries", async () => {
|
||||
mockFetchActivityFeed.mockResolvedValue([]);
|
||||
mockFetchActivityLog.mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<ActivityLogModal
|
||||
@@ -193,7 +227,7 @@ describe("ActivityLogModal", () => {
|
||||
});
|
||||
|
||||
it("shows error state when API fails", async () => {
|
||||
mockFetchActivityFeed.mockRejectedValue(new Error("API Error"));
|
||||
mockFetchActivityLog.mockRejectedValue(new Error("API Error"));
|
||||
|
||||
render(
|
||||
<ActivityLogModal
|
||||
@@ -270,7 +304,7 @@ describe("ActivityLogModal", () => {
|
||||
|
||||
const projectFilter = await screen.findByTestId("activity-project-filter");
|
||||
expect(projectFilter).toBeTruthy();
|
||||
|
||||
|
||||
// Should have "All Projects" option
|
||||
expect(screen.getByText("All Projects")).toBeDefined();
|
||||
// Should have project options
|
||||
@@ -318,6 +352,7 @@ describe("ActivityLogModal", () => {
|
||||
});
|
||||
|
||||
it("shows empty state message mentioning filters when filter is active", async () => {
|
||||
mockFetchActivityLog.mockResolvedValue([]);
|
||||
mockFetchActivityFeed.mockResolvedValue([]);
|
||||
const mockProjects = [
|
||||
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
|
||||
|
||||
@@ -1,42 +1,53 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, waitFor, act } from "@testing-library/react";
|
||||
import { useActivityLog } from "./useActivityLog";
|
||||
import * as apiModule from "../api";
|
||||
import type { ActivityFeedEntry } from "../api";
|
||||
|
||||
function mockFetchResponse(
|
||||
ok: boolean,
|
||||
body: unknown,
|
||||
status = ok ? 200 : 500,
|
||||
contentType = "application/json"
|
||||
) {
|
||||
const bodyText = JSON.stringify(body);
|
||||
return Promise.resolve({
|
||||
ok,
|
||||
status,
|
||||
statusText: ok ? "OK" : "Error",
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "content-type" ? contentType : null,
|
||||
},
|
||||
json: () => Promise.resolve(body),
|
||||
text: () => Promise.resolve(bodyText),
|
||||
} as unknown as Response);
|
||||
// Mock the API module
|
||||
vi.mock("../api", () => ({
|
||||
fetchActivityFeed: vi.fn(),
|
||||
fetchActivityLog: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchActivityFeed = vi.mocked(apiModule.fetchActivityFeed);
|
||||
const mockFetchActivityLog = vi.mocked(apiModule.fetchActivityLog);
|
||||
|
||||
/** Create ActivityFeedEntry[] entries (unified feed format) */
|
||||
function createFeedEntries(
|
||||
count: number,
|
||||
projectId = "proj_123",
|
||||
projectName = "Test Project",
|
||||
): ActivityFeedEntry[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: `feed_entry_${i}`,
|
||||
timestamp: new Date(Date.now() - i * 60000).toISOString(),
|
||||
type: "task:created" as const,
|
||||
projectId,
|
||||
projectName,
|
||||
taskId: "FN-001",
|
||||
taskTitle: "Test Task",
|
||||
details: "Task created",
|
||||
}));
|
||||
}
|
||||
|
||||
describe("useActivityLog", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
// Default: both mocks return empty arrays
|
||||
mockFetchActivityFeed.mockResolvedValue([]);
|
||||
mockFetchActivityLog.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ── Single-project mode (default) ─────────────────────────────────
|
||||
|
||||
it("initializes with empty entries and loads on mount", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
mockFetchActivityLog.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useActivityLog());
|
||||
|
||||
@@ -48,21 +59,24 @@ describe("useActivityLog", () => {
|
||||
});
|
||||
|
||||
expect(result.current.entries).toEqual([]);
|
||||
// Should use per-project log, not unified feed
|
||||
expect(mockFetchActivityLog).toHaveBeenCalled();
|
||||
expect(mockFetchActivityFeed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches and displays activity entries", async () => {
|
||||
const mockEntries: ActivityFeedEntry[] = [
|
||||
{
|
||||
id: "entry_1",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
type: "task:created",
|
||||
projectId: "proj_123",
|
||||
projectName: "Test Project",
|
||||
taskId: "FN-001",
|
||||
details: "Task created",
|
||||
},
|
||||
];
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockEntries));
|
||||
it("fetches entries from per-project log in single-project mode", async () => {
|
||||
const mockEntries = createFeedEntries(1);
|
||||
mockFetchActivityLog.mockResolvedValue(
|
||||
mockEntries.map((e) => ({
|
||||
id: e.id,
|
||||
timestamp: e.timestamp,
|
||||
type: e.type,
|
||||
taskId: e.taskId,
|
||||
taskTitle: e.taskTitle,
|
||||
details: e.details,
|
||||
metadata: e.metadata,
|
||||
})),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useActivityLog());
|
||||
|
||||
@@ -70,68 +84,54 @@ describe("useActivityLog", () => {
|
||||
expect(result.current.entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Hook converts ActivityLogEntry to ActivityFeedEntry with empty project fields
|
||||
expect(result.current.entries[0].type).toBe("task:created");
|
||||
expect(result.current.entries[0].projectName).toBe("Test Project");
|
||||
expect(mockFetchActivityLog).toHaveBeenCalled();
|
||||
expect(mockFetchActivityFeed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("filters by projectId", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
renderHook(() => useActivityLog({ projectId: "proj_123" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("projectId=proj_123"),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("filters by type", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
it("filters by type via per-project log", async () => {
|
||||
mockFetchActivityLog.mockResolvedValue([]);
|
||||
|
||||
renderHook(() => useActivityLog({ type: "task:created" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("type=task%3Acreated"),
|
||||
expect.any(Object)
|
||||
expect(mockFetchActivityLog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "task:created" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("respects custom limit", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
it("respects custom limit via per-project log", async () => {
|
||||
mockFetchActivityLog.mockResolvedValue([]);
|
||||
|
||||
renderHook(() => useActivityLog({ limit: 100 }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("limit=100"),
|
||||
expect.any(Object)
|
||||
expect(mockFetchActivityLog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ limit: 100 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not auto-refresh when disabled", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
mockFetchActivityLog.mockResolvedValue([]);
|
||||
|
||||
renderHook(() => useActivityLog({ autoRefresh: false }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetchActivityLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Fast forward time (but not using fake timers for this test)
|
||||
// Advance time — should not trigger another fetch
|
||||
vi.useRealTimers();
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
// Should still be 1
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetchActivityLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refresh function manually refreshes data", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
mockFetchActivityLog.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useActivityLog({ autoRefresh: false }));
|
||||
|
||||
@@ -144,22 +144,22 @@ describe("useActivityLog", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetchActivityLog).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("clear removes all entries", async () => {
|
||||
const mockEntries: ActivityFeedEntry[] = [
|
||||
{
|
||||
id: "entry_1",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
type: "task:created",
|
||||
projectId: "proj_123",
|
||||
projectName: "Test Project",
|
||||
details: "Task created",
|
||||
},
|
||||
];
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockEntries));
|
||||
const mockEntries = createFeedEntries(1);
|
||||
mockFetchActivityLog.mockResolvedValue(
|
||||
mockEntries.map((e) => ({
|
||||
id: e.id,
|
||||
timestamp: e.timestamp,
|
||||
type: e.type,
|
||||
taskId: e.taskId,
|
||||
taskTitle: e.taskTitle,
|
||||
details: e.details,
|
||||
})),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useActivityLog());
|
||||
|
||||
@@ -176,7 +176,7 @@ describe("useActivityLog", () => {
|
||||
});
|
||||
|
||||
it("handles errors gracefully", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Server error" }, 500));
|
||||
mockFetchActivityLog.mockRejectedValue(new Error("Server error"));
|
||||
|
||||
const { result } = renderHook(() => useActivityLog());
|
||||
|
||||
@@ -188,15 +188,17 @@ describe("useActivityLog", () => {
|
||||
});
|
||||
|
||||
it("sets hasMore when entries equal limit", async () => {
|
||||
const mockEntries: ActivityFeedEntry[] = Array.from({ length: 50 }, (_, i) => ({
|
||||
id: `entry_${i}`,
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
type: "task:created" as const,
|
||||
projectId: "proj_123",
|
||||
projectName: "Test Project",
|
||||
details: "Task created",
|
||||
}));
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockEntries));
|
||||
const mockEntries = createFeedEntries(50);
|
||||
mockFetchActivityLog.mockResolvedValue(
|
||||
mockEntries.map((e) => ({
|
||||
id: e.id,
|
||||
timestamp: e.timestamp,
|
||||
type: e.type,
|
||||
taskId: e.taskId,
|
||||
taskTitle: e.taskTitle,
|
||||
details: e.details,
|
||||
})),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useActivityLog({ limit: 50 }));
|
||||
|
||||
@@ -208,15 +210,17 @@ describe("useActivityLog", () => {
|
||||
});
|
||||
|
||||
it("sets hasMore to false when fewer entries than limit", async () => {
|
||||
const mockEntries: ActivityFeedEntry[] = Array.from({ length: 30 }, (_, i) => ({
|
||||
id: `entry_${i}`,
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
type: "task:created" as const,
|
||||
projectId: "proj_123",
|
||||
projectName: "Test Project",
|
||||
details: "Task created",
|
||||
}));
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockEntries));
|
||||
const mockEntries = createFeedEntries(30);
|
||||
mockFetchActivityLog.mockResolvedValue(
|
||||
mockEntries.map((e) => ({
|
||||
id: e.id,
|
||||
timestamp: e.timestamp,
|
||||
type: e.type,
|
||||
taskId: e.taskId,
|
||||
taskTitle: e.taskTitle,
|
||||
details: e.details,
|
||||
})),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useActivityLog({ limit: 50 }));
|
||||
|
||||
@@ -226,4 +230,51 @@ describe("useActivityLog", () => {
|
||||
|
||||
expect(result.current.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
// ── Multi-project mode (useCentralFeed) ───────────────────────────
|
||||
|
||||
it("fetches from unified feed when useCentralFeed is true", async () => {
|
||||
const mockEntries = createFeedEntries(2, "proj_multi", "Multi Project");
|
||||
mockFetchActivityFeed.mockResolvedValue(mockEntries);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useActivityLog({ useCentralFeed: true }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(2);
|
||||
});
|
||||
|
||||
expect(result.current.entries[0].projectName).toBe("Multi Project");
|
||||
expect(mockFetchActivityFeed).toHaveBeenCalled();
|
||||
expect(mockFetchActivityLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes projectId to unified feed when useCentralFeed is true", async () => {
|
||||
mockFetchActivityFeed.mockResolvedValue([]);
|
||||
|
||||
renderHook(() =>
|
||||
useActivityLog({ projectId: "proj_456", useCentralFeed: true }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchActivityFeed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ projectId: "proj_456" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("passes type filter to unified feed when useCentralFeed is true", async () => {
|
||||
mockFetchActivityFeed.mockResolvedValue([]);
|
||||
|
||||
renderHook(() =>
|
||||
useActivityLog({ type: "task:failed", useCentralFeed: true }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchActivityFeed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "task:failed" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { ActivityFeedEntry } from "../api";
|
||||
import { fetchActivityFeed } from "../api";
|
||||
import { fetchActivityFeed, fetchActivityLog } from "../api";
|
||||
|
||||
export interface UseActivityLogResult {
|
||||
/** Activity log entries */
|
||||
@@ -22,7 +22,7 @@ export interface UseActivityLogResult {
|
||||
const POLL_INTERVAL_MS = 5000; // 5 seconds
|
||||
|
||||
export interface UseActivityLogOptions {
|
||||
/** Filter by project ID */
|
||||
/** Filter by project ID (used with unified central feed) */
|
||||
projectId?: string;
|
||||
/** Filter by event type */
|
||||
type?: ActivityFeedEntry["type"];
|
||||
@@ -30,16 +30,31 @@ export interface UseActivityLogOptions {
|
||||
limit?: number;
|
||||
/** Whether to auto-refresh */
|
||||
autoRefresh?: boolean;
|
||||
/**
|
||||
* When true, fetch from the unified central activity feed (/api/activity-feed).
|
||||
* When false (default), fetch from the per-project activity log (/api/activity).
|
||||
*
|
||||
* Set to true when the modal operates in a multi-project context (projects
|
||||
* list provided) so it reads from the unified feed. Default (false) reads
|
||||
* from the per-project log which is always populated with task events.
|
||||
*/
|
||||
useCentralFeed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching and managing the activity log.
|
||||
* Automatically polls for updates every 5 seconds when enabled.
|
||||
* Supports filtering by project and event type.
|
||||
*
|
||||
* Data source selection:
|
||||
* - Default (single-project): reads from per-project activity log (/api/activity)
|
||||
* which is always populated with task lifecycle events for the current project.
|
||||
* - Multi-project (useCentralFeed=true): reads from unified activity feed
|
||||
* (/api/activity-feed) which aggregates activity across all registered projects.
|
||||
*/
|
||||
export function useActivityLog(options: UseActivityLogOptions = {}): UseActivityLogResult {
|
||||
const { projectId, type, limit = 50, autoRefresh = true } = options;
|
||||
|
||||
const { projectId, type, limit = 50, autoRefresh = true, useCentralFeed = false } = options;
|
||||
|
||||
const [entries, setEntries] = useState<ActivityFeedEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -47,15 +62,39 @@ export function useActivityLog(options: UseActivityLogOptions = {}): UseActivity
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastTimestampRef = useRef<string | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Fetch entries using the appropriate data source.
|
||||
*
|
||||
* Per-project log (/api/activity) — the default — reads directly from the
|
||||
* project's own SQLite database and always contains task lifecycle events.
|
||||
*
|
||||
* Unified feed (/api/activity-feed) reads from the central database and
|
||||
* supports cross-project aggregation.
|
||||
*/
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const data = await fetchActivityFeed({ limit, projectId, type });
|
||||
|
||||
let data: ActivityFeedEntry[];
|
||||
|
||||
if (useCentralFeed) {
|
||||
data = await fetchActivityFeed({ limit, projectId, type });
|
||||
} else {
|
||||
// Per-project: fetchActivityLog returns ActivityLogEntry[] which is a
|
||||
// subset of ActivityFeedEntry (missing projectId/projectName). Map to
|
||||
// the full shape so downstream consumers see a uniform interface.
|
||||
const logEntries = await fetchActivityLog({ limit, type });
|
||||
data = logEntries.map((entry) => ({
|
||||
...entry,
|
||||
projectId: projectId ?? "",
|
||||
projectName: "",
|
||||
}));
|
||||
}
|
||||
|
||||
setEntries(data);
|
||||
setHasMore(data.length === limit);
|
||||
|
||||
|
||||
if (data.length > 0) {
|
||||
lastTimestampRef.current = data[data.length - 1].timestamp;
|
||||
}
|
||||
@@ -64,24 +103,39 @@ export function useActivityLog(options: UseActivityLogOptions = {}): UseActivity
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [limit, projectId, type]);
|
||||
}, [limit, projectId, type, useCentralFeed]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!lastTimestampRef.current) return;
|
||||
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const data = await fetchActivityFeed({
|
||||
limit,
|
||||
projectId,
|
||||
type,
|
||||
since: lastTimestampRef.current
|
||||
});
|
||||
|
||||
|
||||
let data: ActivityFeedEntry[];
|
||||
|
||||
if (useCentralFeed) {
|
||||
data = await fetchActivityFeed({
|
||||
limit,
|
||||
projectId,
|
||||
type,
|
||||
since: lastTimestampRef.current,
|
||||
});
|
||||
} else {
|
||||
const logEntries = await fetchActivityLog({
|
||||
limit,
|
||||
type,
|
||||
since: lastTimestampRef.current,
|
||||
});
|
||||
data = logEntries.map((entry) => ({
|
||||
...entry,
|
||||
projectId: projectId ?? "",
|
||||
projectName: "",
|
||||
}));
|
||||
}
|
||||
|
||||
setEntries((prev) => [...prev, ...data]);
|
||||
setHasMore(data.length === limit);
|
||||
|
||||
|
||||
if (data.length > 0) {
|
||||
lastTimestampRef.current = data[data.length - 1].timestamp;
|
||||
}
|
||||
@@ -90,7 +144,7 @@ export function useActivityLog(options: UseActivityLogOptions = {}): UseActivity
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [limit, projectId, type]);
|
||||
}, [limit, projectId, type, useCentralFeed]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setEntries([]);
|
||||
|
||||
@@ -12143,6 +12143,65 @@ html .column.drag-over * {
|
||||
}
|
||||
}
|
||||
|
||||
/* Active filters bar */
|
||||
.activity-log-active-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.activity-log-filter-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.activity-log-filter-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.activity-log-clear-filters {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.activity-log-clear-filters:hover {
|
||||
background: var(--card-hover);
|
||||
color: var(--text);
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Project filter variant */
|
||||
.activity-log-filter--project {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
/* Confirmation dialog */
|
||||
.activity-log-confirm-overlay {
|
||||
position: absolute;
|
||||
|
||||
Reference in New Issue
Block a user