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.
|
||||
- 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
|
||||
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">
|
||||
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;");
|
||||
});
|
||||
});
|
||||
@@ -4422,6 +4422,19 @@ body {
|
||||
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 {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -5630,10 +5643,15 @@ body {
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
padding: 6px 8px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.settings-sidebar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-nav-item {
|
||||
border-left: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
@@ -5656,6 +5674,13 @@ body {
|
||||
.settings-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-content input,
|
||||
.settings-content select,
|
||||
.settings-content textarea {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.settings-section-heading {
|
||||
@@ -5667,12 +5692,23 @@ body {
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.settings-content .btn,
|
||||
.auth-provider-row .btn {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.auth-provider-row {
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 14px;
|
||||
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-btn {
|
||||
opacity: 1;
|
||||
@@ -18214,9 +18250,16 @@ html .column.drag-over * {
|
||||
|
||||
.executor-status-bar {
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
gap: var(--space-xs);
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
height: 32px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.executor-status-bar__segment {
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.executor-status-bar__label {
|
||||
@@ -18226,6 +18269,10 @@ html .column.drag-over * {
|
||||
.executor-status-bar__divider {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.background-tasks-indicator {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Light theme support */
|
||||
@@ -18300,12 +18347,12 @@ html .column.drag-over * {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.background-tasks-indicator__sessions {
|
||||
.background-tasks-indicator__popover-list {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.background-tasks-indicator__session {
|
||||
.background-tasks-indicator__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -18314,7 +18361,7 @@ html .column.drag-over * {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.background-tasks-indicator__session:last-child {
|
||||
.background-tasks-indicator__item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@@ -18341,7 +18388,7 @@ html .column.drag-over * {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.background-tasks-indicator__dismiss {
|
||||
.background-tasks-indicator__item-dismiss {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -18351,6 +18398,21 @@ html .column.drag-over * {
|
||||
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 === */
|
||||
.directory-picker {
|
||||
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 {
|
||||
margin-bottom: var(--space-lg);
|
||||
@@ -21724,6 +21843,17 @@ html .column.drag-over * {
|
||||
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 === */
|
||||
.agent-dialog-overlay {
|
||||
position: fixed;
|
||||
|
||||
Reference in New Issue
Block a user