feat(FN-1141): improve dashboard mobile adaptations
- Extend mobile CSS for SettingsModal, AgentsView, ExecutorStatusBar, ActiveAgentsPanel, toast placement, and background task popovers - Rename BackgroundTasksIndicator list/item class hooks to align with the updated responsive styling contract - Add focused component tests covering settings, agents, and utility mobile adaptations plus key CSS media-rule assertions - Document mobile component adaptation behavior and testing guidance in the dashboard README
This commit is contained in:
@@ -68,7 +68,7 @@ export function BackgroundTasksIndicator({
|
||||
<div className="background-tasks-indicator__popover-header">
|
||||
Background Tasks
|
||||
</div>
|
||||
<div className="background-tasks-indicator__sessions">
|
||||
<div className="background-tasks-indicator__popover-list">
|
||||
{sessions.map((session) => {
|
||||
const Icon = TYPE_ICONS[session.type];
|
||||
const isGenerating = session.status === "generating";
|
||||
@@ -77,7 +77,7 @@ export function BackgroundTasksIndicator({
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className="background-tasks-indicator__session"
|
||||
className="background-tasks-indicator__item"
|
||||
onClick={() => {
|
||||
onOpenSession(session);
|
||||
setPopoverOpen(false);
|
||||
@@ -112,7 +112,7 @@ export function BackgroundTasksIndicator({
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className="background-tasks-indicator__dismiss"
|
||||
className="background-tasks-indicator__item-dismiss"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDismissSession(session.id);
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { AgentsView } from "../AgentsView";
|
||||
import type { Agent, AgentCapability, AgentState } from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchAgents: vi.fn(),
|
||||
fetchAgentStats: vi.fn(),
|
||||
createAgent: vi.fn(),
|
||||
updateAgent: vi.fn(),
|
||||
updateAgentState: vi.fn(),
|
||||
deleteAgent: vi.fn(),
|
||||
startAgentRun: vi.fn(),
|
||||
fetchModels: vi.fn(() => Promise.resolve({ models: [] })),
|
||||
}));
|
||||
|
||||
import {
|
||||
fetchAgents,
|
||||
fetchAgentStats,
|
||||
updateAgent,
|
||||
updateAgentState,
|
||||
deleteAgent,
|
||||
startAgentRun,
|
||||
} from "../../api";
|
||||
|
||||
const mockAgents: Agent[] = [
|
||||
{
|
||||
id: "agent-001",
|
||||
name: "Mobile Executor",
|
||||
role: "executor" as AgentCapability,
|
||||
state: "active" as AgentState,
|
||||
taskId: "FN-101",
|
||||
lastHeartbeatAt: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
},
|
||||
{
|
||||
id: "agent-002",
|
||||
name: "Mobile Reviewer",
|
||||
role: "reviewer" as AgentCapability,
|
||||
state: "idle" as AgentState,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
},
|
||||
];
|
||||
|
||||
const eventSourceFactory = vi.fn(() => ({
|
||||
addEventListener: vi.fn(),
|
||||
close: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("AgentsView mobile adaptations", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
vi.stubGlobal("EventSource", eventSourceFactory as unknown as typeof EventSource);
|
||||
|
||||
vi.mocked(fetchAgents).mockResolvedValue(mockAgents);
|
||||
vi.mocked(fetchAgentStats).mockResolvedValue({
|
||||
total: 2,
|
||||
byState: { active: 1, idle: 1 },
|
||||
byRole: { executor: 1, reviewer: 1 },
|
||||
});
|
||||
vi.mocked(updateAgent).mockResolvedValue(mockAgents[0]);
|
||||
vi.mocked(updateAgentState).mockResolvedValue(mockAgents[0]);
|
||||
vi.mocked(deleteAgent).mockResolvedValue(undefined);
|
||||
vi.mocked(startAgentRun).mockResolvedValue({
|
||||
id: "run-1",
|
||||
agentId: "agent-001",
|
||||
startedAt: new Date().toISOString(),
|
||||
endedAt: null,
|
||||
status: "active",
|
||||
});
|
||||
});
|
||||
|
||||
it("renders board view grid and board cards", async () => {
|
||||
const { container } = render(<AgentsView addToast={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByText("Agents")).toBeTruthy());
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Board view" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(".agent-board")).toBeTruthy();
|
||||
expect(container.querySelectorAll(".agent-board-card").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders list view cards", async () => {
|
||||
const { container } = render(<AgentsView addToast={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByText("Agents")).toBeTruthy());
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "List view" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(".agent-list")).toBeTruthy();
|
||||
expect(container.querySelectorAll(".agent-card").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders agent controls, filter, and action buttons", async () => {
|
||||
const { container } = render(<AgentsView addToast={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByText("Agents")).toBeTruthy());
|
||||
|
||||
expect(container.querySelector(".agent-controls")).toBeTruthy();
|
||||
expect(container.querySelector(".agent-state-filter")).toBeTruthy();
|
||||
expect(container.querySelector(".agent-controls-actions")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("switches between board, list, and tree views", async () => {
|
||||
const { container } = render(<AgentsView addToast={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByText("Agents")).toBeTruthy());
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Tree view" }));
|
||||
await waitFor(() => expect(container.querySelector(".agent-tree__view")).toBeTruthy());
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Board view" }));
|
||||
await waitFor(() => expect(container.querySelector(".agent-board")).toBeTruthy());
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "List view" }));
|
||||
await waitFor(() => expect(container.querySelector(".agent-list")).toBeTruthy());
|
||||
});
|
||||
|
||||
it("renders state filter select with expected options", async () => {
|
||||
render(<AgentsView addToast={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByLabelText("Filter agents by state")).toBeTruthy());
|
||||
|
||||
const select = screen.getByLabelText("Filter agents by state") as HTMLSelectElement;
|
||||
expect(select).toBeTruthy();
|
||||
|
||||
const optionValues = Array.from(select.options).map((option) => option.value);
|
||||
expect(optionValues).toEqual(["all", "idle", "active", "running", "paused", "error", "terminated"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SettingsModal } from "../SettingsModal";
|
||||
import type { Settings } from "@fusion/core";
|
||||
|
||||
const stylesPath = path.resolve(__dirname, "../../styles.css");
|
||||
|
||||
const defaultSettings = {
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 15_000,
|
||||
groupOverlappingFiles: false,
|
||||
autoMerge: true,
|
||||
mergeStrategy: "direct",
|
||||
recycleWorktrees: false,
|
||||
worktreeInitCommand: "",
|
||||
testCommand: "",
|
||||
buildCommand: "",
|
||||
autoResolveConflicts: true,
|
||||
smartConflictResolution: true,
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
ntfyEnabled: false,
|
||||
ntfyTopic: undefined,
|
||||
ntfyEvents: ["in-review", "merged", "failed", "awaiting-approval"],
|
||||
taskStuckTimeoutMs: undefined,
|
||||
maxStuckKills: 6,
|
||||
runStepsInNewSessions: false,
|
||||
maxParallelSteps: 2,
|
||||
} as Settings;
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
||||
updateSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
||||
updateGlobalSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
|
||||
fetchAuthStatus: vi.fn(() => Promise.resolve({ providers: [{ id: "anthropic", name: "Anthropic", authenticated: false }] })),
|
||||
loginProvider: vi.fn(() => Promise.resolve({ url: "https://auth.example.com/login" })),
|
||||
logoutProvider: vi.fn(() => Promise.resolve({ success: true })),
|
||||
saveApiKey: vi.fn(() => Promise.resolve({ success: true })),
|
||||
clearApiKey: vi.fn(() => Promise.resolve({ success: true })),
|
||||
fetchModels: vi.fn(() => Promise.resolve({ models: [], favoriteProviders: [], favoriteModels: [] })),
|
||||
testNtfyNotification: vi.fn(() => Promise.resolve({ success: true })),
|
||||
fetchBackups: vi.fn(() => Promise.resolve({ count: 0, totalSize: 0, backups: [] })),
|
||||
createBackup: vi.fn(() => Promise.resolve({ success: true })),
|
||||
exportSettings: vi.fn(() => Promise.resolve({ success: true, data: {} })),
|
||||
importSettings: vi.fn(() => Promise.resolve({ success: true })),
|
||||
fetchMemory: vi.fn(() => Promise.resolve({ memory: "" })),
|
||||
saveMemory: vi.fn(() => Promise.resolve({ success: true })),
|
||||
}));
|
||||
|
||||
import { fetchSettings } from "../../api";
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function expectMobileRule(css: string, selector: string, declaration: string): void {
|
||||
const pattern = new RegExp(
|
||||
`@media\\s*\\(max-width:\\s*768px\\)\\s*\\{[\\s\\S]*?${escapeRegExp(selector)}\\s*\\{[\\s\\S]*?${escapeRegExp(declaration)}`,
|
||||
);
|
||||
expect(pattern.test(css)).toBe(true);
|
||||
}
|
||||
|
||||
describe("SettingsModal mobile adaptations", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders mobile-targeted settings layout classes", async () => {
|
||||
const { container } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
expect(container.querySelector(".settings-layout")).toBeTruthy();
|
||||
expect(container.querySelector(".settings-sidebar")).toBeTruthy();
|
||||
expect(container.querySelector(".settings-content")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders settings nav items with active class for touch styling", async () => {
|
||||
const { container } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
const navItems = container.querySelectorAll(".settings-nav-item");
|
||||
expect(navItems.length).toBeGreaterThan(0);
|
||||
expect(container.querySelector(".settings-nav-item.active")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders form controls inside settings-content for 16px mobile targeting", async () => {
|
||||
const { container } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
const controls = container.querySelectorAll(".settings-content input, .settings-content select, .settings-content textarea");
|
||||
expect(controls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows scope indicators and updates scope banner across sections", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container, getByText } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
expect(container.querySelectorAll(".settings-scope-icon").length).toBeGreaterThan(0);
|
||||
expect(getByText("These settings only affect this project.")).toBeTruthy();
|
||||
|
||||
await user.click(getByText("Appearance"));
|
||||
expect(getByText("These settings are shared across all your kb projects.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("contains required mobile settings CSS overrides", () => {
|
||||
const css = fs.readFileSync(stylesPath, "utf-8");
|
||||
|
||||
expectMobileRule(css, ".settings-layout", "flex-direction: column;");
|
||||
expectMobileRule(css, ".settings-sidebar", "flex-direction: row;");
|
||||
expectMobileRule(css, ".settings-sidebar", "overflow-x: auto;");
|
||||
expectMobileRule(css, ".settings-sidebar", "scrollbar-width: none;");
|
||||
expectMobileRule(css, ".settings-sidebar::-webkit-scrollbar", "display: none;");
|
||||
expectMobileRule(css, ".settings-content textarea", "font-size: 16px;");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { Agent, AiSessionSummary } from "../../api";
|
||||
import type { Toast } from "../../hooks/useToast";
|
||||
|
||||
vi.mock("../../hooks/useExecutorStats", () => ({
|
||||
useExecutorStats: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useLiveTranscript", () => ({
|
||||
useLiveTranscript: vi.fn(() => ({
|
||||
entries: [],
|
||||
isConnected: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { useExecutorStats } from "../../hooks/useExecutorStats";
|
||||
import { BackgroundTasksIndicator } from "../BackgroundTasksIndicator";
|
||||
import { ExecutorStatusBar } from "../ExecutorStatusBar";
|
||||
import { ActiveAgentsPanel } from "../ActiveAgentsPanel";
|
||||
import { ToastContainer } from "../ToastContainer";
|
||||
|
||||
const stylesPath = path.resolve(__dirname, "../../styles.css");
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function expectMobileRule(css: string, selector: string, declaration: string): void {
|
||||
const pattern = new RegExp(
|
||||
`@media\\s*\\(max-width:\\s*768px\\)\\s*\\{[\\s\\S]*?${escapeRegExp(selector)}\\s*\\{[\\s\\S]*?${escapeRegExp(declaration)}`,
|
||||
);
|
||||
expect(pattern.test(css)).toBe(true);
|
||||
}
|
||||
|
||||
describe("Utility component mobile adaptations", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useExecutorStats).mockReturnValue({
|
||||
stats: {
|
||||
runningTaskCount: 1,
|
||||
blockedTaskCount: 2,
|
||||
stuckTaskCount: 0,
|
||||
queuedTaskCount: 3,
|
||||
inReviewCount: 4,
|
||||
executorState: "running",
|
||||
maxConcurrent: 5,
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it("renders BackgroundTasksIndicator pill when sessions exist", () => {
|
||||
const sessions: AiSessionSummary[] = [
|
||||
{
|
||||
id: "sess-1",
|
||||
type: "planning",
|
||||
status: "generating",
|
||||
title: "Refine onboarding flow",
|
||||
projectId: "proj-1",
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<BackgroundTasksIndicator
|
||||
sessions={sessions}
|
||||
generating={1}
|
||||
needsInput={0}
|
||||
onOpenSession={vi.fn()}
|
||||
onDismissSession={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /AI 1/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders BackgroundTasksIndicator popover on pill click", () => {
|
||||
const sessions: AiSessionSummary[] = [
|
||||
{
|
||||
id: "sess-2",
|
||||
type: "subtask",
|
||||
status: "awaiting_input",
|
||||
title: "Break down API tasks",
|
||||
projectId: "proj-1",
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<BackgroundTasksIndicator
|
||||
sessions={sessions}
|
||||
generating={0}
|
||||
needsInput={1}
|
||||
onOpenSession={vi.fn()}
|
||||
onDismissSession={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /AI 1/i }));
|
||||
|
||||
expect(screen.getByText("Background Tasks")).toBeTruthy();
|
||||
expect(screen.getByText("Break down API tasks")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns null for BackgroundTasksIndicator with no sessions", () => {
|
||||
const { container } = render(
|
||||
<BackgroundTasksIndicator
|
||||
sessions={[]}
|
||||
generating={0}
|
||||
needsInput={0}
|
||||
onOpenSession={vi.fn()}
|
||||
onDismissSession={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders ExecutorStatusBar segments", () => {
|
||||
render(<ExecutorStatusBar tasks={[]} />);
|
||||
|
||||
const bar = screen.getByRole("status");
|
||||
expect(bar).toHaveTextContent("Running");
|
||||
expect(bar).toHaveTextContent("Blocked");
|
||||
expect(bar).toHaveTextContent("Queued");
|
||||
expect(bar).toHaveTextContent("In Review");
|
||||
});
|
||||
|
||||
it("renders ActiveAgentsPanel grid and cards when agents are provided", () => {
|
||||
const agents: Agent[] = [
|
||||
{
|
||||
id: "agent-1",
|
||||
name: "Live Agent",
|
||||
role: "executor",
|
||||
state: "active",
|
||||
taskId: "FN-555",
|
||||
lastHeartbeatAt: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(<ActiveAgentsPanel agents={agents} />);
|
||||
|
||||
expect(container.querySelector(".active-agents-grid")).toBeTruthy();
|
||||
expect(container.querySelectorAll(".live-agent-card").length).toBe(1);
|
||||
});
|
||||
|
||||
it("returns null for ActiveAgentsPanel when no agents are active", () => {
|
||||
const { container } = render(<ActiveAgentsPanel agents={[]} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders toasts in ToastContainer", () => {
|
||||
const toasts: Toast[] = [
|
||||
{ id: 1, message: "Saved", type: "success" },
|
||||
{ id: 2, message: "Failed", type: "error" },
|
||||
];
|
||||
|
||||
const { container } = render(<ToastContainer toasts={toasts} onRemove={vi.fn()} />);
|
||||
|
||||
expect(container.querySelector(".toast-container")).toBeTruthy();
|
||||
expect(container.querySelector(".toast-success")).toBeTruthy();
|
||||
expect(container.querySelector(".toast-error")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("contains mobile CSS overrides for adapted utility and layout components", () => {
|
||||
const css = fs.readFileSync(stylesPath, "utf-8");
|
||||
|
||||
expectMobileRule(css, ".settings-layout", "flex-direction: column;");
|
||||
expectMobileRule(css, ".agent-board", "grid-template-columns: 1fr;");
|
||||
expectMobileRule(css, ".active-agents-grid", "grid-template-columns: 1fr;");
|
||||
expectMobileRule(css, ".toast-container", "bottom: calc(44px + env(safe-area-inset-bottom, 0px));");
|
||||
expectMobileRule(css, ".background-tasks-indicator__popover", "position: fixed;");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user