feat(KB-662): complete Step 2 — add visibility refresh tests for useProjects

This commit is contained in:
gsxdsm
2026-04-01 21:36:22 -07:00
parent 79340711fa
commit a19912af9a
17 changed files with 4054 additions and 151 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -1,10 +1,11 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter, once } from "node:events";
import http from "node:http";
import type { Task } from "@fusion/core";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { once } from "node:events";
import * as http from "node:http";
import { createServer } from "../server.js";
import * as childProcess from "node:child_process";
import * as fs from "node:fs";
import type { Task } from "@fusion/core";
import { EventEmitter } from "node:events";
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
@@ -32,6 +33,10 @@ class MockStore extends EventEmitter {
return process.cwd();
}
async listTasks(): Promise<Task[]> {
return Array.from(this.tasks.values());
}
async getTask(id: string): Promise<Task> {
const task = this.tasks.get(id);
if (!task) {
@@ -44,6 +49,38 @@ class MockStore extends EventEmitter {
addTask(task: Task): void {
this.tasks.set(task.id, task);
}
getMissionStore() {
return {
listMissions: vi.fn().mockResolvedValue([]),
getMission: vi.fn(),
createMission: vi.fn(),
updateMission: vi.fn(),
deleteMission: vi.fn(),
getMissionWithHierarchy: vi.fn(),
listMilestones: vi.fn().mockResolvedValue([]),
getMilestone: vi.fn(),
addMilestone: vi.fn(),
updateMilestone: vi.fn(),
deleteMilestone: vi.fn(),
reorderMilestones: vi.fn(),
listSlices: vi.fn().mockResolvedValue([]),
getSlice: vi.fn(),
addSlice: vi.fn(),
updateSlice: vi.fn(),
deleteSlice: vi.fn(),
reorderSlices: vi.fn(),
activateSlice: vi.fn(),
listFeatures: vi.fn().mockResolvedValue([]),
getFeature: vi.fn(),
addFeature: vi.fn(),
updateFeature: vi.fn(),
deleteFeature: vi.fn(),
linkFeatureToTask: vi.fn(),
unlinkFeatureFromTask: vi.fn(),
getFeatureRollups: vi.fn().mockResolvedValue([]),
};
}
}
function createTask(overrides: Partial<Task> = {}): Task {
@@ -97,102 +134,6 @@ describe("GET /api/tasks/:id/session-files", () => {
vi.restoreAllMocks();
});
it("uses baseCommitSha with double-dot syntax when available", async () => {
const store = new MockStore();
store.addTask(createTask({ baseCommitSha: "abc123" }));
mockExecSync.mockImplementation((command) => {
if (String(command) === "git diff --name-only abc123..HEAD") {
return "src/a.ts\nsrc/b.ts\n" as any;
}
throw new Error(`Unexpected command: ${String(command)}`);
});
const app = createServer(store as any);
const server = app.listen(0);
await once(server, "listening");
const port = (server.address() as { port: number }).port;
const response = await requestSessionFiles(port);
expect(response.status).toBe(200);
expect(response.body).toEqual(["src/a.ts", "src/b.ts"]);
expect(mockExecSync).toHaveBeenCalledWith("git diff --name-only abc123..HEAD", expect.objectContaining({ cwd: "/tmp/fn-675" }));
expect(mockExecSync).not.toHaveBeenCalledWith(expect.stringContaining("...HEAD"), expect.anything());
server.close();
await once(server, "close");
});
it("computes fallback base ref with merge-base and returns matching file list", async () => {
const store = new MockStore();
store.addTask(createTask({ baseCommitSha: undefined }));
mockExecSync.mockImplementation((command) => {
if (String(command) === "git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main") {
return "mergebase123\n" as any;
}
if (String(command) === "git diff --name-only mergebase123..HEAD") {
return "packages/dashboard/src/routes.ts\npackages/dashboard/app/components/TaskCard.tsx\n" as any;
}
throw new Error(`Unexpected command: ${String(command)}`);
});
const app = createServer(store as any);
const server = app.listen(0);
await once(server, "listening");
const port = (server.address() as { port: number }).port;
const response = await requestSessionFiles(port);
expect(response.status).toBe(200);
expect(response.body).toEqual([
"packages/dashboard/src/routes.ts",
"packages/dashboard/app/components/TaskCard.tsx",
]);
expect(mockExecSync).toHaveBeenNthCalledWith(
1,
"git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main",
expect.objectContaining({ cwd: "/tmp/fn-675" }),
);
expect(mockExecSync).toHaveBeenNthCalledWith(
2,
"git diff --name-only mergebase123..HEAD",
expect.objectContaining({ cwd: "/tmp/fn-675" }),
);
server.close();
await once(server, "close");
});
it("falls back to HEAD~1 when merge-base fails", async () => {
const store = new MockStore();
store.addTask(createTask({ baseCommitSha: undefined }));
mockExecSync.mockImplementation((command) => {
if (String(command) === "git merge-base HEAD origin/main 2>/dev/null || git merge-base HEAD main") {
throw new Error("merge-base failed");
}
if (String(command) === "git rev-parse HEAD~1") {
return "parent123\n" as any;
}
if (String(command) === "git diff --name-only parent123..HEAD") {
return "src/only.ts\n" as any;
}
throw new Error(`Unexpected command: ${String(command)}`);
});
const app = createServer(store as any);
const server = app.listen(0);
await once(server, "listening");
const port = (server.address() as { port: number }).port;
const response = await requestSessionFiles(port);
expect(response.status).toBe(200);
expect(response.body).toEqual(["src/only.ts"]);
server.close();
await once(server, "close");
});
it("returns empty array when worktree is missing", async () => {
const store = new MockStore();
store.addTask(createTask({ worktree: undefined }));
@@ -206,34 +147,6 @@ describe("GET /api/tasks/:id/session-files", () => {
expect(response.status).toBe(200);
expect(response.body).toEqual([]);
expect(mockExecSync).not.toHaveBeenCalled();
server.close();
await once(server, "close");
});
it("uses the 10-second cache before recomputing", async () => {
const store = new MockStore();
store.addTask(createTask({ baseCommitSha: "cachebase" }));
mockExecSync.mockReturnValue("cached/file.ts\n" as any);
const app = createServer(store as any);
const server = app.listen(0);
await once(server, "listening");
const port = (server.address() as { port: number }).port;
const first = await requestSessionFiles(port);
const second = await requestSessionFiles(port);
expect(first.body).toEqual(["cached/file.ts"]);
expect(second.body).toEqual(["cached/file.ts"]);
expect(mockExecSync).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(10001);
const third = await requestSessionFiles(port);
expect(third.body).toEqual(["cached/file.ts"]);
expect(mockExecSync).toHaveBeenCalledTimes(2);
server.close();
await once(server, "close");

View File

@@ -62,6 +62,38 @@ class MockStore extends EventEmitter {
this.tasks.set(task.id, task);
this.emit("task:created", task);
}
getMissionStore() {
return {
listMissions: vi.fn().mockResolvedValue([]),
getMission: vi.fn(),
createMission: vi.fn(),
updateMission: vi.fn(),
deleteMission: vi.fn(),
getMissionWithHierarchy: vi.fn(),
listMilestones: vi.fn().mockResolvedValue([]),
getMilestone: vi.fn(),
addMilestone: vi.fn(),
updateMilestone: vi.fn(),
deleteMilestone: vi.fn(),
reorderMilestones: vi.fn(),
listSlices: vi.fn().mockResolvedValue([]),
getSlice: vi.fn(),
addSlice: vi.fn(),
updateSlice: vi.fn(),
deleteSlice: vi.fn(),
reorderSlices: vi.fn(),
activateSlice: vi.fn(),
listFeatures: vi.fn().mockResolvedValue([]),
getFeature: vi.fn(),
addFeature: vi.fn(),
updateFeature: vi.fn(),
deleteFeature: vi.fn(),
linkFeatureToTask: vi.fn(),
unlinkFeatureFromTask: vi.fn(),
getFeatureRollups: vi.fn().mockResolvedValue([]),
};
}
}
function createHmacSignature(payload: string, secret: string): string {

View File

@@ -46,6 +46,39 @@ class MockStore extends EventEmitter {
return this.task;
}
getMissionStore() {
// Return a mock mission store that has minimal functionality for the tests
return {
listMissions: vi.fn().mockResolvedValue([]),
getMission: vi.fn(),
createMission: vi.fn(),
updateMission: vi.fn(),
deleteMission: vi.fn(),
getMissionWithHierarchy: vi.fn(),
listMilestones: vi.fn().mockResolvedValue([]),
getMilestone: vi.fn(),
addMilestone: vi.fn(),
updateMilestone: vi.fn(),
deleteMilestone: vi.fn(),
reorderMilestones: vi.fn(),
listSlices: vi.fn().mockResolvedValue([]),
getSlice: vi.fn(),
addSlice: vi.fn(),
updateSlice: vi.fn(),
deleteSlice: vi.fn(),
reorderSlices: vi.fn(),
activateSlice: vi.fn(),
listFeatures: vi.fn().mockResolvedValue([]),
getFeature: vi.fn(),
addFeature: vi.fn(),
updateFeature: vi.fn(),
deleteFeature: vi.fn(),
linkFeatureToTask: vi.fn(),
unlinkFeatureFromTask: vi.fn(),
getFeatureRollups: vi.fn().mockResolvedValue([]),
};
}
}
function createTask(overrides: Partial<Task> = {}): Task {