fix(FN-820): fix activity log data source selection in project view

- Fix ActivityLogModal to correctly select between project and central activity logs based on project context
- Add regression tests verifying data source selection logic for single-project and multi-project scenarios
- Update README documentation with activity log data source rules and selection behavior
This commit is contained in:
gsxdsm
2026-04-04 00:13:37 -07:00
parent 9d2c055f32
commit 5b0f0d2943
4 changed files with 110 additions and 10 deletions

View File

@@ -224,8 +224,8 @@ Browse and edit task worktree files directly from the task detail modal:
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.
- **Project view** (when a project is selected): Reads from the per-project activity log via `/api/activity`, which is always populated with task lifecycle events for the current project. This ensures reliable activity visibility in normal dashboard use.
- **Overview mode** (no project selected): Reads from the unified central feed via `/api/activity-feed`, which aggregates activity across all registered projects. The 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

View File

@@ -732,6 +732,7 @@ function AppInner() {
tasks={tasks}
projectId={currentProject?.id}
projects={projects}
currentProject={currentProject}
onOpenTaskDetail={(taskId) => {
const task = tasks.find((t) => t.id === taskId);
if (task) {

View File

@@ -15,6 +15,8 @@ interface ActivityLogModalProps {
projects?: ProjectInfo[];
/** Called when project filter changes */
onProjectFilterChange?: (projectId: string | undefined) => void;
/** Current project context - when set, uses per-project activity log */
currentProject?: ProjectInfo | null;
}
const EVENT_TYPE_LABELS: Record<ActivityEventType, string> = {
@@ -57,10 +59,11 @@ 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
* - Project view (currentProject set): 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
* - Overview mode (no currentProject): reads from the unified central
* feed via /api/activity-feed, which aggregates activity across all projects.
* The project filter dropdown allows narrowing results to a specific project.
*
* Features:
* - Project name badge for each activity entry
@@ -76,6 +79,7 @@ export function ActivityLogModal({
projectId,
projects = [],
onProjectFilterChange,
currentProject,
}: ActivityLogModalProps) {
const [filteredType, setFilteredType] = useState<ActivityEventType | "all">("all");
const [filteredProjectId, setFilteredProjectId] = useState<string | "all">(projectId || "all");
@@ -90,10 +94,14 @@ 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;
// Determine data source:
// - In project view (currentProject set): use per-project activity log (/api/activity)
// which is always populated with task lifecycle events for the current project.
// - In overview mode (no currentProject): use unified central feed (/api/activity-feed)
// which aggregates activity across all registered projects.
// The project filter dropdown (projects prop) still appears to filter by project,
// but the default data source is the per-project log in project view.
const useCentralFeed = !currentProject && projects.length > 0;
// Use the hook for data fetching
const {

View File

@@ -145,7 +145,7 @@ describe("ActivityLogModal", () => {
});
});
it("calls unified feed API when projects are provided (multi-project mode)", async () => {
it("calls unified feed API when projects are provided but no currentProject (overview mode)", async () => {
const mockProjects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
@@ -161,11 +161,102 @@ describe("ActivityLogModal", () => {
);
await waitFor(() => {
// Multi-project mode: uses fetchActivityFeed (not fetchActivityLog)
// Overview mode (no currentProject): uses fetchActivityFeed
expect(mockFetchActivityFeed).toHaveBeenCalled();
});
});
// ── Regression Tests for FN-820 ────────────────────────────────────
// The bug was that the modal used the unified central feed whenever projects
// existed, even in normal project view where per-project activity log should be used.
it("uses per-project log when currentProject is set even with multiple projects registered", async () => {
const mockProjects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
const mockCurrentProject = { 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}
currentProject={mockCurrentProject}
onOpenTaskDetail={mockOnOpenTaskDetail}
/>
);
await waitFor(() => {
// Project view (currentProject set): uses per-project log even with multiple projects
expect(mockFetchActivityLog).toHaveBeenCalled();
expect(mockFetchActivityFeed).not.toHaveBeenCalled();
});
});
it("uses per-project log when currentProject is set and projects list is empty", async () => {
const mockCurrentProject = { 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}
currentProject={mockCurrentProject}
onOpenTaskDetail={mockOnOpenTaskDetail}
/>
);
await waitFor(() => {
// Project view: uses per-project log
expect(mockFetchActivityLog).toHaveBeenCalled();
expect(mockFetchActivityFeed).not.toHaveBeenCalled();
});
});
it("uses unified feed in overview mode with multiple projects but no currentProject", async () => {
const mockProjects = [
{ id: "proj_1", name: "Project One", path: "/path/1", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
{ id: "proj_2", name: "Project Two", path: "/path/2", status: "active" as const, isolationMode: "in-process" as const, createdAt: "", updatedAt: "" },
];
render(
<ActivityLogModal
isOpen={true}
onClose={mockOnClose}
tasks={mockTasks}
projects={mockProjects}
// No currentProject - overview mode
onOpenTaskDetail={mockOnOpenTaskDetail}
/>
);
await waitFor(() => {
// Overview mode: uses unified feed
expect(mockFetchActivityFeed).toHaveBeenCalled();
expect(mockFetchActivityLog).not.toHaveBeenCalled();
});
});
it("uses per-project log by default when only projectId is passed (backward compatible)", async () => {
render(
<ActivityLogModal
isOpen={true}
onClose={mockOnClose}
tasks={mockTasks}
projectId="proj_1"
onOpenTaskDetail={mockOnOpenTaskDetail}
/>
);
await waitFor(() => {
// Default behavior without projects: uses per-project log
expect(mockFetchActivityLog).toHaveBeenCalled();
expect(mockFetchActivityFeed).not.toHaveBeenCalled();
});
});
it("filters by type when dropdown changed", async () => {
render(
<ActivityLogModal