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:
gsxdsm
2026-04-08 08:48:21 -07:00
parent 69621e78dc
commit 4eef067baa
6 changed files with 598 additions and 8 deletions

View File

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

View File

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

View File

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