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:
gsxdsm
2026-04-03 14:05:40 -07:00
parent 074755fefa
commit d0156e4b8a
8 changed files with 350 additions and 128 deletions

View File

@@ -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

View File

@@ -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) {

View File

@@ -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

View File

@@ -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: "" },

View File

@@ -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" }),
);
});
});
});

View File

@@ -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([]);

View File

@@ -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;