feat(KB-662): complete Step 2 — add visibility refresh tests for useProjects
This commit is contained in:
@@ -287,13 +287,6 @@ export function fetchTaskComments(id: string): Promise<TaskComment[]> {
|
||||
return api<TaskComment[]>(`/tasks/${id}/comments`);
|
||||
}
|
||||
|
||||
export function addComment(id: string, text: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/steer`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
}
|
||||
|
||||
export function addTaskComment(id: string, text: string, author?: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/comments`, {
|
||||
method: "POST",
|
||||
|
||||
1287
packages/dashboard/app/components/AgentDetailView.tsx
Normal file
1287
packages/dashboard/app/components/AgentDetailView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -117,7 +117,7 @@ describe("FileBrowserModal", () => {
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /kb/i }));
|
||||
await user.click(screen.getByRole("button", { name: /KB-002 Task Two/i }));
|
||||
await user.click(screen.getByRole("button", { name: /FN-002 Task Two/i }));
|
||||
|
||||
expect(mockOnWorkspaceChange).toHaveBeenCalledWith("FN-002");
|
||||
});
|
||||
|
||||
1305
packages/dashboard/app/components/MissionManager.tsx
Normal file
1305
packages/dashboard/app/components/MissionManager.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -37,8 +37,10 @@ export function QuickScriptsDropdown({
|
||||
return Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b));
|
||||
}, [scripts]);
|
||||
|
||||
const showFooter = scriptEntries.length > 0;
|
||||
|
||||
// Total items for keyboard navigation (scripts + "Manage Scripts...")
|
||||
const totalItems = scriptEntries.length + 1;
|
||||
const totalItems = scriptEntries.length + (showFooter ? 1 : 0);
|
||||
|
||||
// Fetch scripts when dropdown opens
|
||||
useEffect(() => {
|
||||
@@ -135,7 +137,7 @@ export function QuickScriptsDropdown({
|
||||
// Run script
|
||||
const [name, command] = scriptEntries[highlightedIndex];
|
||||
handleRunScript(name, command);
|
||||
} else {
|
||||
} else if (showFooter && highlightedIndex === scriptEntries.length) {
|
||||
// Manage Scripts...
|
||||
handleManageScripts();
|
||||
}
|
||||
@@ -151,7 +153,7 @@ export function QuickScriptsDropdown({
|
||||
break;
|
||||
}
|
||||
},
|
||||
[highlightedIndex, totalItems, scriptEntries]
|
||||
[highlightedIndex, totalItems, scriptEntries, showFooter]
|
||||
);
|
||||
|
||||
// Toggle dropdown
|
||||
@@ -179,7 +181,7 @@ export function QuickScriptsDropdown({
|
||||
{/* Trigger button */}
|
||||
<button
|
||||
ref={triggerRef}
|
||||
className={`quick-scripts-dropdown__trigger ${isOpen ? "open" : ""}`}
|
||||
className={`btn-icon quick-scripts-dropdown__trigger ${isOpen ? "open btn-icon--active" : ""}`}
|
||||
onClick={toggleDropdown}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
@@ -212,9 +214,12 @@ export function QuickScriptsDropdown({
|
||||
</div>
|
||||
) : scriptEntries.length === 0 ? (
|
||||
<div className="quick-scripts-dropdown__empty" data-testid="quick-scripts-empty">
|
||||
<div className="quick-scripts-dropdown__empty-icon">
|
||||
<Terminal size={16} />
|
||||
</div>
|
||||
<p>No scripts configured</p>
|
||||
<button
|
||||
className="quick-scripts-dropdown__empty-action"
|
||||
className="quick-scripts-dropdown__empty-action btn"
|
||||
onClick={handleManageScripts}
|
||||
>
|
||||
Add your first script
|
||||
@@ -250,7 +255,7 @@ export function QuickScriptsDropdown({
|
||||
<div className="quick-scripts-dropdown__footer">
|
||||
<button
|
||||
className={`quick-scripts-dropdown__manage ${
|
||||
highlightedIndex === scriptEntries.length ? "highlighted" : ""
|
||||
showFooter && highlightedIndex === scriptEntries.length ? "highlighted" : ""
|
||||
}`}
|
||||
onClick={handleManageScripts}
|
||||
data-testid="quick-scripts-manage"
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "@testing-library/jest-dom";
|
||||
import { AgentDetailView } from "../AgentDetailView";
|
||||
import type { AgentCapability, AgentDetail } from "../../api";
|
||||
|
||||
// Mock the API functions
|
||||
vi.mock("../../api", () => ({
|
||||
fetchAgent: vi.fn(),
|
||||
updateAgentState: vi.fn(),
|
||||
deleteAgent: vi.fn(),
|
||||
fetchAgentLogs: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchAgent, updateAgentState } from "../../api";
|
||||
|
||||
const mockFetchAgent = vi.mocked(fetchAgent);
|
||||
const mockUpdateAgentState = vi.mocked(updateAgentState);
|
||||
|
||||
describe("AgentDetailView", () => {
|
||||
const createMockAgent = (overrides: Partial<{
|
||||
id: string;
|
||||
name: string;
|
||||
role: AgentCapability;
|
||||
state: "idle" | "active" | "paused" | "terminated";
|
||||
taskId?: string;
|
||||
}> = {}): AgentDetail => ({
|
||||
id: "agent-001",
|
||||
name: "Test Agent",
|
||||
role: "executor" as AgentCapability,
|
||||
state: "active",
|
||||
taskId: "FN-001",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
lastHeartbeatAt: "2024-01-01T00:05:00.000Z",
|
||||
metadata: {},
|
||||
heartbeatHistory: [],
|
||||
activeRun: {
|
||||
id: "run-001",
|
||||
agentId: "agent-001",
|
||||
startedAt: "2024-01-01T00:00:00.000Z",
|
||||
endedAt: null,
|
||||
status: "active",
|
||||
},
|
||||
completedRuns: [
|
||||
{
|
||||
id: "run-002",
|
||||
agentId: "agent-001",
|
||||
startedAt: "2023-12-31T00:00:00.000Z",
|
||||
endedAt: "2023-12-31T00:05:00.000Z",
|
||||
status: "completed",
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
} as AgentDetail);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent());
|
||||
mockUpdateAgentState.mockResolvedValue(createMockAgent({ state: "paused" }));
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Loading agent/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays agent name in header after loading", async () => {
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Wait for the h2 element specifically (the header title)
|
||||
await waitFor(() => {
|
||||
const headings = screen.getAllByRole("heading", { level: 2 });
|
||||
expect(headings.some(h => h.textContent === "Test Agent")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("displays role badge", async () => {
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("executor")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("displays state badge", async () => {
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// There should be at least one element with "active" (could be in badge or inline-badge)
|
||||
expect(screen.getAllByText("active").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows all tabs", async () => {
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Dashboard")).toBeInTheDocument();
|
||||
expect(screen.getByText("Logs")).toBeInTheDocument();
|
||||
expect(screen.getByText("Runs")).toBeInTheDocument();
|
||||
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Pause button for active agent", async () => {
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Pause")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Resume button for paused agent", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "paused" }));
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Resume")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Delete button for terminated agent", async () => {
|
||||
mockFetchAgent.mockResolvedValue(createMockAgent({ state: "terminated" }));
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Delete")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows statistics section on dashboard", async () => {
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Total Runs")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("displays agent ID in footer", async () => {
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("agent-001")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls API with correct agentId", async () => {
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgent).toHaveBeenCalledWith("agent-001");
|
||||
});
|
||||
});
|
||||
|
||||
it("displays health status indicator", async () => {
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// Health status should be either Healthy, Unresponsive, or Idle
|
||||
const healthTexts = ["Healthy", "Unresponsive", "Idle"];
|
||||
const hasHealthStatus = healthTexts.some(text =>
|
||||
document.body.textContent?.includes(text)
|
||||
);
|
||||
expect(hasHealthStatus).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Live Run on runs tab when agent has active run", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Dashboard")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText("Runs"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Live Run")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,13 +3,13 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { TaskComments } from "../TaskComments";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
addComment: vi.fn(),
|
||||
addSteeringComment: vi.fn(),
|
||||
addTaskComment: vi.fn(),
|
||||
updateTaskComment: vi.fn(),
|
||||
deleteTaskComment: vi.fn(),
|
||||
}));
|
||||
|
||||
import { addComment, addTaskComment, updateTaskComment, deleteTaskComment } from "../../api";
|
||||
import { addSteeringComment, addTaskComment, updateTaskComment, deleteTaskComment } from "../../api";
|
||||
|
||||
const makeTask = (overrides: any = {}) => ({
|
||||
id: "FN-001",
|
||||
@@ -155,8 +155,8 @@ describe("TaskComments", () => {
|
||||
expect(screen.getByText(/AI Guidance comments are injected into the task execution context/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses addComment API when AI Guidance type is selected", async () => {
|
||||
vi.mocked(addComment).mockResolvedValue(makeTask({
|
||||
it("uses addSteeringComment API when AI Guidance type is selected", async () => {
|
||||
vi.mocked(addSteeringComment).mockResolvedValue(makeTask({
|
||||
comments: [{ id: "c1", text: "Guidance text", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }],
|
||||
}));
|
||||
|
||||
@@ -170,7 +170,7 @@ describe("TaskComments", () => {
|
||||
fireEvent.click(screen.getByText("Add Guidance"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addComment).toHaveBeenCalledWith("FN-001", "Guidance text");
|
||||
expect(addSteeringComment).toHaveBeenCalledWith("FN-001", "Guidance text");
|
||||
expect(addTaskComment).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -188,7 +188,7 @@ describe("TaskComments", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addTaskComment).toHaveBeenCalledWith("FN-001", "User text", "user");
|
||||
expect(addComment).not.toHaveBeenCalled();
|
||||
expect(addSteeringComment).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
375
packages/dashboard/app/hooks/__tests__/useProjects.test.ts
Normal file
375
packages/dashboard/app/hooks/__tests__/useProjects.test.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useProjects } from "../useProjects";
|
||||
import * as api from "../../api";
|
||||
import type { ProjectInfo } from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchProjects: vi.fn(),
|
||||
registerProject: vi.fn(),
|
||||
unregisterProject: vi.fn(),
|
||||
updateProject: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchProjects = vi.mocked(api.fetchProjects);
|
||||
const mockUpdateProject = vi.mocked(api.updateProject);
|
||||
const mockRegisterProject = vi.mocked(api.registerProject);
|
||||
const mockUnregisterProject = vi.mocked(api.unregisterProject);
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("useProjects", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
mockFetchProjects.mockReset();
|
||||
mockUpdateProject.mockReset();
|
||||
mockRegisterProject.mockReset();
|
||||
mockUnregisterProject.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("visibility change", () => {
|
||||
let originalVisibilityState: PropertyDescriptor | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalVisibilityState) {
|
||||
Object.defineProperty(document, "visibilityState", originalVisibilityState);
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (document as any).visibilityState;
|
||||
}
|
||||
});
|
||||
|
||||
function setVisibilityState(state: "visible" | "hidden") {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: state,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function dispatchVisibilityChange() {
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
it("refetches projects when visibility changes from hidden to visible", async () => {
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
|
||||
const initialProject: ProjectInfo = {
|
||||
id: "proj_001",
|
||||
name: "Initial Project",
|
||||
path: "/initial/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
const refreshedProject: ProjectInfo = {
|
||||
id: "proj_001",
|
||||
name: "Updated Project",
|
||||
path: "/initial/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-02T00:00:00.000Z",
|
||||
};
|
||||
|
||||
mockFetchProjects.mockResolvedValueOnce([initialProject]).mockResolvedValueOnce([refreshedProject]);
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.projects).toHaveLength(1);
|
||||
expect(result.current.projects[0].name).toBe("Initial Project");
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:01.100Z"));
|
||||
setVisibilityState("hidden");
|
||||
await dispatchVisibilityChange();
|
||||
|
||||
setVisibilityState("visible");
|
||||
await dispatchVisibilityChange();
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.projects[0].name).toBe("Updated Project");
|
||||
expect(mockFetchProjects).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not refetch when visibility changes to hidden", async () => {
|
||||
const initialProject: ProjectInfo = {
|
||||
id: "proj_001",
|
||||
name: "Test Project",
|
||||
path: "/test/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
mockFetchProjects.mockResolvedValueOnce([initialProject]);
|
||||
|
||||
renderHook(() => useProjects());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
mockFetchProjects.mockClear();
|
||||
|
||||
setVisibilityState("hidden");
|
||||
await dispatchVisibilityChange();
|
||||
|
||||
expect(mockFetchProjects).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("debounces rapid visibility changes (minimum 1 second between fetches)", async () => {
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
|
||||
const initialProject: ProjectInfo = {
|
||||
id: "proj_001",
|
||||
name: "Test Project",
|
||||
path: "/test/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
mockFetchProjects.mockResolvedValue([initialProject]);
|
||||
|
||||
renderHook(() => useProjects());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
mockFetchProjects.mockClear();
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:01.100Z"));
|
||||
setVisibilityState("hidden");
|
||||
await dispatchVisibilityChange();
|
||||
|
||||
setVisibilityState("visible");
|
||||
await dispatchVisibilityChange();
|
||||
|
||||
expect(mockFetchProjects).toHaveBeenCalledTimes(1);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
setVisibilityState("hidden");
|
||||
await dispatchVisibilityChange();
|
||||
|
||||
setVisibilityState("visible");
|
||||
await dispatchVisibilityChange();
|
||||
}
|
||||
|
||||
expect(mockFetchProjects).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:02.200Z"));
|
||||
setVisibilityState("hidden");
|
||||
await dispatchVisibilityChange();
|
||||
|
||||
setVisibilityState("visible");
|
||||
await dispatchVisibilityChange();
|
||||
|
||||
expect(mockFetchProjects).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("cleans up visibility change listener on unmount", async () => {
|
||||
mockFetchProjects.mockResolvedValueOnce([]);
|
||||
|
||||
const removeEventListenerSpy = vi.spyOn(document, "removeEventListener");
|
||||
|
||||
const { unmount } = renderHook(() => useProjects());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchProjects).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith("visibilitychange", expect.any(Function));
|
||||
|
||||
removeEventListenerSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("basic functionality", () => {
|
||||
it("fetches projects on mount", async () => {
|
||||
const mockProjects: ProjectInfo[] = [
|
||||
{
|
||||
id: "proj_001",
|
||||
name: "Test Project",
|
||||
path: "/test/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
mockFetchProjects.mockResolvedValueOnce(mockProjects);
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.projects).toHaveLength(1);
|
||||
expect(result.current.projects[0].name).toBe("Test Project");
|
||||
});
|
||||
|
||||
it("handles errors gracefully", async () => {
|
||||
mockFetchProjects.mockRejectedValueOnce(new Error("Failed to fetch"));
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.error).toBe("Failed to fetch");
|
||||
});
|
||||
|
||||
it("register adds project optimistically", async () => {
|
||||
const newProject: ProjectInfo = {
|
||||
id: "proj_new",
|
||||
name: "New Project",
|
||||
path: "/new/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
mockFetchProjects.mockResolvedValueOnce([]);
|
||||
mockRegisterProject.mockResolvedValueOnce(newProject);
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.projects).toHaveLength(0);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.register({ name: "New Project", path: "/new/path" });
|
||||
});
|
||||
|
||||
expect(result.current.projects).toHaveLength(1);
|
||||
expect(result.current.projects[0].id).toBe("proj_new");
|
||||
});
|
||||
|
||||
it("unregister removes project optimistically", async () => {
|
||||
const mockProjects: ProjectInfo[] = [
|
||||
{
|
||||
id: "proj_001",
|
||||
name: "Test Project",
|
||||
path: "/test/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
mockFetchProjects.mockResolvedValueOnce(mockProjects);
|
||||
mockUnregisterProject.mockResolvedValueOnce(undefined);
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.projects).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.unregister("proj_001");
|
||||
});
|
||||
|
||||
expect(result.current.projects).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("update modifies project optimistically", async () => {
|
||||
const mockProjects: ProjectInfo[] = [
|
||||
{
|
||||
id: "proj_001",
|
||||
name: "Test Project",
|
||||
path: "/test/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
const updatedProject: ProjectInfo = {
|
||||
...mockProjects[0],
|
||||
name: "Updated Name",
|
||||
};
|
||||
mockFetchProjects.mockResolvedValueOnce(mockProjects);
|
||||
mockUpdateProject.mockResolvedValueOnce(updatedProject);
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.projects[0].name).toBe("Test Project");
|
||||
|
||||
await act(async () => {
|
||||
await result.current.update("proj_001", { name: "Updated Name" });
|
||||
});
|
||||
|
||||
expect(result.current.projects[0].name).toBe("Updated Name");
|
||||
});
|
||||
|
||||
it("refresh manually refetches projects", async () => {
|
||||
const initialProject: ProjectInfo = {
|
||||
id: "proj_001",
|
||||
name: "Initial",
|
||||
path: "/test/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
const refreshedProject: ProjectInfo = {
|
||||
...initialProject,
|
||||
name: "Refreshed",
|
||||
};
|
||||
mockFetchProjects.mockResolvedValueOnce([initialProject]).mockResolvedValueOnce([refreshedProject]);
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.projects[0].name).toBe("Initial");
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
|
||||
expect(result.current.projects[0].name).toBe("Refreshed");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -26,10 +26,12 @@ export interface UseProjectsResult {
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 5000; // 5 seconds
|
||||
const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000;
|
||||
|
||||
/**
|
||||
* Hook for fetching and managing projects.
|
||||
* Automatically polls for updates every 5 seconds.
|
||||
* Refetches when the tab becomes visible again.
|
||||
* Provides optimistic updates for UI responsiveness.
|
||||
*/
|
||||
export function useProjects(): UseProjectsResult {
|
||||
@@ -37,6 +39,7 @@ export function useProjects(): UseProjectsResult {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastVisibilityRefreshRef = useRef<number>(0);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
@@ -49,7 +52,7 @@ export function useProjects(): UseProjectsResult {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initial fetch
|
||||
// Initial fetch and visibility change handler
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -72,12 +75,29 @@ export function useProjects(): UseProjectsResult {
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
void load();
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState !== "visible") {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const timeSinceLastRefresh = now - lastVisibilityRefreshRef.current;
|
||||
if (timeSinceLastRefresh < VISIBILITY_REFRESH_DEBOUNCE_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastVisibilityRefreshRef.current = now;
|
||||
void refresh();
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
}, [refresh]);
|
||||
|
||||
// Polling for updates
|
||||
useEffect(() => {
|
||||
|
||||
@@ -131,6 +131,154 @@ body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__trigger {
|
||||
gap: 4px;
|
||||
width: auto;
|
||||
min-width: 28px;
|
||||
padding: 0 7px;
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__trigger-chevron {
|
||||
color: currentColor;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__trigger-chevron.rotate {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
width: min(320px, 72vw);
|
||||
min-width: 260px;
|
||||
padding: 6px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 120;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__loading,
|
||||
.quick-scripts-dropdown__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 18px 16px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__empty-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
background: var(--card);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__empty p {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__empty-action {
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: min(360px, 60vh);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__item,
|
||||
.quick-scripts-dropdown__manage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__item:hover,
|
||||
.quick-scripts-dropdown__item.highlighted,
|
||||
.quick-scripts-dropdown__manage:hover,
|
||||
.quick-scripts-dropdown__manage.highlighted {
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__item-icon,
|
||||
.quick-scripts-dropdown__manage svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__item-name {
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__item-command {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__footer {
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__manage {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.quick-scripts-dropdown__manage span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1140,6 +1288,492 @@ body {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* === ProjectOverview Component === */
|
||||
.project-overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-xl);
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.project-overview--empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.project-overview--loading {
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
/* --- Overview Header --- */
|
||||
.project-overview__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xl);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.project-overview__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-overview__stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.project-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.project-stat__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.project-stat__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.project-stat__value {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.project-stat__label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.project-stat--active .project-stat__icon {
|
||||
color: var(--in-progress);
|
||||
}
|
||||
|
||||
.project-stat--active .project-stat__value {
|
||||
color: var(--in-progress);
|
||||
}
|
||||
|
||||
.project-stat--completed .project-stat__icon {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.project-stat--completed .project-stat__value {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.project-stat--error .project-stat__icon {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.project-stat--error {
|
||||
border-color: rgba(248, 81, 73, 0.3);
|
||||
background: rgba(248, 81, 73, 0.08);
|
||||
}
|
||||
|
||||
.project-stat--error .project-stat__value {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.project-overview__add-btn {
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/* --- Filter Tabs --- */
|
||||
.project-overview__filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.project-filter-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
|
||||
.project-filter-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.project-filter-tab:hover {
|
||||
color: var(--text);
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.project-filter-tab.active {
|
||||
color: var(--text);
|
||||
background: var(--card);
|
||||
border-color: var(--border);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.project-filter-tab.has-errors {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.project-filter-tab.has-errors.active {
|
||||
background: rgba(248, 81, 73, 0.12);
|
||||
border-color: rgba(248, 81, 73, 0.3);
|
||||
}
|
||||
|
||||
.project-filter-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
background: var(--border);
|
||||
border-radius: 9px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.project-filter-tab.active .project-filter-count {
|
||||
background: var(--text-dim);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.project-filter-tab.has-errors .project-filter-count {
|
||||
background: rgba(248, 81, 73, 0.2);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
/* --- Sort Dropdown --- */
|
||||
.project-sort {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.project-sort-select {
|
||||
appearance: none;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
padding: 6px 28px 6px 10px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-primary);
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.project-sort-select:hover {
|
||||
border-color: var(--text-dim);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.project-sort-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--todo);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
/* --- Project Grid --- */
|
||||
.project-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.project-grid--skeleton {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* --- No Results --- */
|
||||
.project-overview__no-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-2xl);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.project-overview__no-results svg {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.project-overview__no-results p {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* --- Empty State --- */
|
||||
.project-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-2xl);
|
||||
max-width: 480px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.project-empty-state__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: var(--surface);
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.project-empty-state__title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.project-empty-state__description {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.project-empty-state__cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-sm);
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* --- Loading Skeleton --- */
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.project-overview__header-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xl);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.project-overview__stats-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.project-overview__stat-skeleton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.project-overview__filters-skeleton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.project-skeleton {
|
||||
background: var(--card);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.project-skeleton-icon {
|
||||
color: var(--text-dim);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.project-skeleton--icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-md);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.project-skeleton--value {
|
||||
width: 32px;
|
||||
height: 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.project-skeleton--label {
|
||||
width: 48px;
|
||||
height: 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.project-skeleton--tab {
|
||||
width: 80px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.project-skeleton--icon-circle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.project-skeleton__text-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.project-skeleton--title {
|
||||
width: 120px;
|
||||
height: 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.project-skeleton--path {
|
||||
width: 180px;
|
||||
height: 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.project-skeleton--badge {
|
||||
width: 60px;
|
||||
height: 22px;
|
||||
border-radius: var(--radius-sm);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.project-skeleton--metric {
|
||||
width: 60px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-sm);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.project-skeleton--activity {
|
||||
width: 120px;
|
||||
height: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.project-skeleton--actions {
|
||||
width: 160px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Skeleton Card Layout */
|
||||
.project-card--skeleton {
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.project-card-skeleton__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.project-card-skeleton__health {
|
||||
display: flex;
|
||||
gap: var(--space-lg);
|
||||
padding: var(--space-md) 0;
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.project-card-skeleton__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Stagger skeleton animation delays for visual polish */
|
||||
.project-card--skeleton:nth-child(2) .project-skeleton--title { animation-delay: 0.1s; }
|
||||
.project-card--skeleton:nth-child(3) .project-skeleton--title { animation-delay: 0.2s; }
|
||||
.project-card--skeleton:nth-child(4) .project-skeleton--title { animation-delay: 0.3s; }
|
||||
.project-card--skeleton:nth-child(5) .project-skeleton--title { animation-delay: 0.4s; }
|
||||
.project-card--skeleton:nth-child(6) .project-skeleton--title { animation-delay: 0.5s; }
|
||||
|
||||
/* === ActivityFeed Component === */
|
||||
.activity-feed {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user