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:
@@ -160,6 +160,27 @@ Mobile dropdown behavior follows a consistent viewport-aware anchoring pattern s
|
|||||||
- For scrollable dropdown lists and modal content containers, apply `-webkit-overflow-scrolling: touch` to preserve iOS momentum scrolling.
|
- For scrollable dropdown lists and modal content containers, apply `-webkit-overflow-scrolling: touch` to preserve iOS momentum scrolling.
|
||||||
- Use base selectors for reusable scroll lists (for example: `.dep-dropdown`, `.model-combobox-list`, `.quick-scripts-dropdown__list`, `.file-browser-list`) and reinforce modal surfaces in the main mobile media query (`@media (max-width: 768px)`).
|
- Use base selectors for reusable scroll lists (for example: `.dep-dropdown`, `.model-combobox-list`, `.quick-scripts-dropdown__list`, `.file-browser-list`) and reinforce modal surfaces in the main mobile media query (`@media (max-width: 768px)`).
|
||||||
|
|
||||||
|
### Mobile Component Adaptations
|
||||||
|
In addition to the global mobile foundation, several power-user surfaces now include component-specific mobile behavior tuned for touch interaction and narrow viewports (`≤768px`).
|
||||||
|
|
||||||
|
- **SettingsModal**
|
||||||
|
- The settings layout collapses from sidebar/content columns into a stacked flow (`.settings-layout` uses `flex-direction: column`).
|
||||||
|
- The sidebar becomes a horizontally scrollable tab strip (`.settings-sidebar` switches to row layout with hidden scrollbars and touch momentum scrolling).
|
||||||
|
- Settings content remains independently scrollable (`.settings-content` keeps `flex: 1; min-height: 0; overflow-y: auto`) so tabs stay reachable while content scrolls.
|
||||||
|
- Settings form controls inside `.settings-content` enforce `font-size: 16px` on mobile to prevent iOS zoom-on-focus.
|
||||||
|
|
||||||
|
- **AgentsView**
|
||||||
|
- Board mode collapses to a single column (`.agent-board { grid-template-columns: 1fr; }`).
|
||||||
|
- Controls stack vertically (`.agent-controls` + `.agent-controls-actions`) with full-width action buttons and touch-friendly sizing.
|
||||||
|
- State filter stretches full width on mobile.
|
||||||
|
- Tree-view indentation is reduced (`.agent-tree__indent--1..4`) to prevent horizontal overflow at deeper hierarchy levels.
|
||||||
|
|
||||||
|
- **Utility components**
|
||||||
|
- **BackgroundTasksIndicator** popover switches to fixed viewport anchoring on mobile (`left/right: 8px`, `bottom: 40px`) so it is not clipped by parent containers.
|
||||||
|
- **ExecutorStatusBar** mobile layout includes tighter spacing and overflow guards (`min-width: 0`, hidden overflow in segments) for narrow screens.
|
||||||
|
- **ActiveAgentsPanel** grid stacks to one column on mobile (`.active-agents-grid { grid-template-columns: 1fr; }`).
|
||||||
|
- **ToastContainer** shifts above footer surfaces with safe-area awareness (`bottom: calc(44px + env(safe-area-inset-bottom, 0px))`, full-width toasts).
|
||||||
|
|
||||||
### Executor Status Bar
|
### Executor Status Bar
|
||||||
A persistent footer status bar at the bottom of the dashboard displays real-time executor statistics in project view. The status bar provides immediate visibility into the engine's state without opening modals or hovering over badges.
|
A persistent footer status bar at the bottom of the dashboard displays real-time executor statistics in project view. The status bar provides immediate visibility into the engine's state without opening modals or hovering over badges.
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function BackgroundTasksIndicator({
|
|||||||
<div className="background-tasks-indicator__popover-header">
|
<div className="background-tasks-indicator__popover-header">
|
||||||
Background Tasks
|
Background Tasks
|
||||||
</div>
|
</div>
|
||||||
<div className="background-tasks-indicator__sessions">
|
<div className="background-tasks-indicator__popover-list">
|
||||||
{sessions.map((session) => {
|
{sessions.map((session) => {
|
||||||
const Icon = TYPE_ICONS[session.type];
|
const Icon = TYPE_ICONS[session.type];
|
||||||
const isGenerating = session.status === "generating";
|
const isGenerating = session.status === "generating";
|
||||||
@@ -77,7 +77,7 @@ export function BackgroundTasksIndicator({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={session.id}
|
key={session.id}
|
||||||
className="background-tasks-indicator__session"
|
className="background-tasks-indicator__item"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onOpenSession(session);
|
onOpenSession(session);
|
||||||
setPopoverOpen(false);
|
setPopoverOpen(false);
|
||||||
@@ -112,7 +112,7 @@ export function BackgroundTasksIndicator({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className="background-tasks-indicator__dismiss"
|
className="background-tasks-indicator__item-dismiss"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDismissSession(session.id);
|
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;");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4422,6 +4422,19 @@ body {
|
|||||||
background: var(--color-info);
|
background: var(--color-info);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.toast-container {
|
||||||
|
bottom: calc(44px + env(safe-area-inset-bottom, 0px));
|
||||||
|
right: 8px;
|
||||||
|
left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes toast-in {
|
@keyframes toast-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -5630,10 +5643,15 @@ body {
|
|||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-sidebar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-nav-item {
|
.settings-nav-item {
|
||||||
border-left: none;
|
border-left: none;
|
||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
@@ -5656,6 +5674,13 @@ body {
|
|||||||
.settings-content {
|
.settings-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-content input,
|
||||||
|
.settings-content select,
|
||||||
|
.settings-content textarea {
|
||||||
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-section-heading {
|
.settings-section-heading {
|
||||||
@@ -5667,12 +5692,23 @@ body {
|
|||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-content .btn,
|
||||||
|
.auth-provider-row .btn {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-provider-row {
|
.auth-provider-row {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.backup-list ul {
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
/* Card edit button: always visible on mobile (no hover) */
|
/* Card edit button: always visible on mobile (no hover) */
|
||||||
.card-edit-btn {
|
.card-edit-btn {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -18214,9 +18250,16 @@ html .column.drag-over * {
|
|||||||
|
|
||||||
.executor-status-bar {
|
.executor-status-bar {
|
||||||
padding: var(--space-xs) var(--space-md);
|
padding: var(--space-xs) var(--space-md);
|
||||||
gap: var(--space-xs);
|
gap: 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.executor-status-bar__segment {
|
||||||
|
min-width: 0;
|
||||||
|
gap: 4px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.executor-status-bar__label {
|
.executor-status-bar__label {
|
||||||
@@ -18226,6 +18269,10 @@ html .column.drag-over * {
|
|||||||
.executor-status-bar__divider {
|
.executor-status-bar__divider {
|
||||||
height: 12px;
|
height: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.background-tasks-indicator {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Light theme support */
|
/* Light theme support */
|
||||||
@@ -18300,12 +18347,12 @@ html .column.drag-over * {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-tasks-indicator__sessions {
|
.background-tasks-indicator__popover-list {
|
||||||
max-height: 240px;
|
max-height: 240px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-tasks-indicator__session {
|
.background-tasks-indicator__item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -18314,7 +18361,7 @@ html .column.drag-over * {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-tasks-indicator__session:last-child {
|
.background-tasks-indicator__item:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18341,7 +18388,7 @@ html .column.drag-over * {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-tasks-indicator__dismiss {
|
.background-tasks-indicator__item-dismiss {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -18351,6 +18398,21 @@ html .column.drag-over * {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.background-tasks-indicator__popover {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 40px;
|
||||||
|
left: 8px;
|
||||||
|
right: 8px;
|
||||||
|
min-width: auto;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-tasks-indicator__pill {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* === DirectoryPicker === */
|
/* === DirectoryPicker === */
|
||||||
.directory-picker {
|
.directory-picker {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -21631,6 +21693,63 @@ html .column.drag-over * {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.agent-board {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-board-actions .btn,
|
||||||
|
.agent-card-actions .btn {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-controls-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-controls-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-state-filter {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-state-filter-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-view-header {
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agents-view-title h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-tree__indent--1 { padding-left: 16px; }
|
||||||
|
.agent-tree__indent--2 { padding-left: 32px; }
|
||||||
|
.agent-tree__indent--3 { padding-left: 48px; }
|
||||||
|
.agent-tree__indent--4 { padding-left: 64px; }
|
||||||
|
|
||||||
|
.agent-tree__toggle {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* === Active Agents Panel === */
|
/* === Active Agents Panel === */
|
||||||
.active-agents-panel {
|
.active-agents-panel {
|
||||||
margin-bottom: var(--space-lg);
|
margin-bottom: var(--space-lg);
|
||||||
@@ -21724,6 +21843,17 @@ html .column.drag-over * {
|
|||||||
animation: pulse 1.5s infinite;
|
animation: pulse 1.5s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.active-agents-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-agent-card {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* === New Agent Dialog === */
|
/* === New Agent Dialog === */
|
||||||
.agent-dialog-overlay {
|
.agent-dialog-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
Reference in New Issue
Block a user