FN-5972: fix mobile auto-merge blank dashboard

Restore mobile auto-merge dashboard stability and broaden regression coverage.

- share the mobile media query between viewport detection and board stabilization so landscape phones stay on the mobile path
- add an integration regression suite for toggling auto-merge across mobile, tablet, desktop, rollback, and task review surfaces
- expand dashboard and CLI tests, keep the new published changeset, and preserve related vitest coverage lists and safety comments

Files changed:
$(git diff --cached --stat)

Fusion-Task-Id: FN-5972

Fusion-Task-Lineage: f3a3bbae-21c2-451b-b24f-848cc12d0542
This commit is contained in:
gsxdsm
2026-06-07 17:02:48 -07:00
parent ec8e4d3b0d
commit fa23782dbb
39 changed files with 648 additions and 22 deletions

View File

@@ -349,6 +349,25 @@ describe("project commands", () => {
await expect(runProjectAdd("bad name", "/tmp")).rejects.toThrow("process.exit:1");
});
it("prompts for a missing project name in interactive add mode", async () => {
mockListProjects.mockResolvedValue([]);
mockRegisterProject.mockResolvedValue({
id: "proj-1",
name: "demo",
path: ".",
isolationMode: "in-process",
});
mockQuestion.mockResolvedValueOnce("demo");
const { runProjectAdd } = await import("../project.js");
await expect(runProjectAdd("", ".", { force: true })).resolves.toBeUndefined();
expect(mockQuestion).toHaveBeenCalledWith(expect.stringContaining("Project name"));
expect(mockRegisterProject).toHaveBeenCalledWith(
expect.objectContaining({ name: "demo" }),
);
});
it("validation exits on missing required args for runProjectRemove", async () => {
const { runProjectRemove } = await import("../project.js");
await expect(runProjectRemove("")).rejects.toThrow("process.exit:1");

View File

@@ -7,6 +7,7 @@ import type { ToastType } from "../hooks/useToast";
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { fetchWorkflowSteps, fetchBoardWorkflows, promoteTask, type ModelInfo, type BoardWorkflowsPayload } from "../api";
import { useBlockerFanout } from "../hooks/useBlockerFanout";
import { MOBILE_MEDIA_QUERY } from "../hooks/useViewportMode";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
import { subscribeSse } from "../sse-bus";
@@ -199,7 +200,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
// `overflow-anchor: none`) and only stabilize via reflow + scroll offset
// normalization; do NOT reintroduce `scroll-snap-type: x mandatory`.
useEffect(() => {
if (!window.matchMedia("(max-width: 768px)").matches) {
if (!window.matchMedia(MOBILE_MEDIA_QUERY).matches) {
return;
}

View File

@@ -4,7 +4,8 @@ import { AgentsView } from "../AgentsView";
import * as apiModule from "../../api";
const mockViewportMode = vi.fn<() => "mobile" | "tablet" | "desktop">(() => "desktop");
vi.mock("../../hooks/useViewportMode", () => ({ useViewportMode: () => mockViewportMode() }));
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", useViewportMode: () => mockViewportMode() }));
vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: vi.fn().mockResolvedValue(true) }) }));
vi.mock("../AgentDetailView", () => ({ AgentDetailView: () => null, relativeTime: () => "now" }));

View File

@@ -75,6 +75,7 @@ vi.mock("../AgentDetailView", () => ({
const mockViewportMode = vi.fn<() => "mobile" | "tablet" | "desktop">(() => "desktop");
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
useViewportMode: () => mockViewportMode(),
}));

View File

@@ -582,6 +582,7 @@ vi.mock("../../hooks/useMobileKeyboard", () => ({
// depending on window.matchMedia in jsdom.
const mockUseViewportMode = vi.fn(() => "desktop");
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
useViewportMode: (...args: unknown[]) => mockUseViewportMode(...args),
getViewportMode: () => "desktop",
}));
@@ -4188,6 +4189,9 @@ describe("FN-5817 mobile auto-merge toggle stability", () => {
const toggle = await screen.findByRole("checkbox", { name: "Auto-merge" });
expect(screen.getByTestId("mobile-view-toggle")).toBeInTheDocument();
expect(document.querySelector("main.board")).not.toBeNull();
expect(screen.getByText("In review task")).toBeInTheDocument();
expect(screen.queryByText("Something went wrong")).toBeNull();
fireEvent.click(toggle);
@@ -4198,8 +4202,11 @@ describe("FN-5817 mobile auto-merge toggle stability", () => {
);
expect(screen.getByTestId("mobile-view-toggle")).toBeInTheDocument();
expect(screen.getByRole("checkbox", { name: "Auto-merge" })).toBeInTheDocument();
expect(document.querySelector("main.board")).not.toBeNull();
expect(screen.getByText("In review task")).toBeInTheDocument();
});
expect(screen.queryByText("Something went wrong")).toBeNull();
expect(consoleErrorSpy).not.toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});

View File

@@ -45,7 +45,9 @@ function mockViewport(mode: "mobile" | "desktop") {
configurable: true,
});
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches: mode === "mobile" && query === "(max-width: 768px)" || query === "(max-width: 768px), (max-height: 480px)",
matches:
mode === "mobile" &&
(query === "(max-width: 768px)" || query === "(max-width: 768px), (max-height: 480px)"),
media: query,
onchange: null,
addListener: vi.fn(),

View File

@@ -276,7 +276,9 @@ function mockViewportMode(mode: "mobile" | "desktop") {
const isMobile = mode === "mobile";
Object.defineProperty(window, "innerWidth", { value: isMobile ? 375 : 1280, configurable: true });
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches: isMobile && query === "(max-width: 768px)" || query === "(max-width: 768px), (max-height: 480px)",
matches:
isMobile &&
(query === "(max-width: 768px)" || query === "(max-width: 768px), (max-height: 480px)"),
media: query,
onchange: null,
addListener: vi.fn(),

View File

@@ -19,6 +19,7 @@ const mockUseMobileKeyboard = vi.fn(() => ({
}));
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
useViewportMode: () => mockUseViewportMode(),
}));

View File

@@ -28,6 +28,7 @@ vi.mock("../../api", () => ({
}));
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
useViewportMode: vi.fn(),
}));

View File

@@ -65,6 +65,7 @@ vi.mock("../../hooks/useMobileKeyboard", () => ({
}));
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
useViewportMode: () => "mobile",
}));
vi.mock("lucide-react", () => ({

View File

@@ -17,6 +17,7 @@ const mockFetchMissionInterviewDrafts = vi.fn();
const mockSubscribeSse = vi.fn(() => vi.fn());
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
useViewportMode: () => mockViewportMode(),
}));

View File

@@ -20,7 +20,8 @@ vi.mock("../../hooks/useConfirm", () => ({ useConfirm: vi.fn(() => ({ confirm: v
vi.mock("../../hooks/useMobileKeyboard", () => ({ useMobileKeyboard: vi.fn(() => ({ keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false })) }));
vi.mock("../../hooks/useMobileScrollLock", () => ({ useMobileScrollLock: vi.fn() }));
vi.mock("../../hooks/useNodes", () => ({ useNodes: vi.fn(() => ({ nodes: [] })) }));
vi.mock("../../hooks/useViewportMode", () => ({ useViewportMode: vi.fn(() => "desktop") }));
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", useViewportMode: vi.fn(() => "desktop") }));
function deferred<T>() {
let resolve!: (value: T) => void;

View File

@@ -52,6 +52,7 @@ vi.mock("../../hooks/useMobileKeyboard", () => ({
}));
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
useViewportMode: () => "mobile",
}));

View File

@@ -78,6 +78,7 @@ vi.mock("../../hooks/useConfirm", () => ({
}));
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
useViewportMode: () => mockUseViewportMode(),
}));

View File

@@ -100,6 +100,7 @@ vi.mock("../../hooks/useConfirm", () => ({
}));
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
useViewportMode: () => mockUseViewportMode(),
}));

View File

@@ -110,6 +110,7 @@ vi.mock("../../hooks/useConfirm", () => ({
}));
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
useViewportMode: () => mockUseViewportMode(),
}));

View File

@@ -114,6 +114,7 @@ vi.mock("../../hooks/useConfirm", () => ({
}));
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
useViewportMode: () => mockUseViewportMode(),
}));

View File

@@ -101,6 +101,7 @@ vi.mock("../../hooks/useConfirm", () => ({
}));
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
useViewportMode: () => mockUseViewportMode(),
}));

View File

@@ -11,6 +11,7 @@ const mockFetchModels = vi.fn();
const mockSubscribeSse = vi.fn(() => vi.fn());
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
useViewportMode: () => mockViewportMode(),
}));

View File

@@ -111,6 +111,7 @@ vi.mock("../../hooks/useConfirm", () => ({
}));
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
useViewportMode: () => mockUseViewportMode(),
}));

View File

@@ -85,6 +85,7 @@ vi.mock("../../hooks/useMobileKeyboard", () => ({
}));
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
useViewportMode: vi.fn(() => "desktop"),
}));

View File

@@ -46,7 +46,8 @@ vi.mock("../../hooks/useQuickChat", () => ({
}));
vi.mock("../../hooks/useFileMention", () => ({ useFileMention: vi.fn(() => ({ mentionActive: false, detectMention: vi.fn(), dismissMention: vi.fn(), handleKeyDown: vi.fn(), selectTask: vi.fn(), selectFile: vi.fn(), tasks: [], files: [], combinedItems: [], loading: false, mentionQuery: "", selectedIndex: 0, setSelectedIndex: vi.fn() })) }));
vi.mock("../../hooks/useMobileKeyboard", () => ({ useMobileKeyboard: vi.fn(() => ({ keyboardOpen: false, keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0 })) }));
vi.mock("../../hooks/useViewportMode", () => ({ useViewportMode: vi.fn(() => "desktop") }));
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", useViewportMode: vi.fn(() => "desktop") }));
vi.mock("react-markdown", () => ({ default: ({ children }: { children: string }) => children }));
function deferred<T>() {

View File

@@ -28,7 +28,8 @@ vi.mock("../../api", () => ({
}));
vi.mock("../../hooks/useAgents", () => ({ useAgents: vi.fn() }));
vi.mock("../../hooks/useViewportMode", () => ({ useViewportMode: vi.fn() }));
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", useViewportMode: vi.fn() }));
vi.mock("../../hooks/useMobileKeyboard", () => ({ useMobileKeyboard: vi.fn() }));
vi.mock("../../hooks/useAppSettings", () => ({ useAppSettings: vi.fn() }));
vi.mock("../../hooks/useChatRooms", () => ({ useChatRooms: vi.fn() }));

View File

@@ -47,7 +47,7 @@ function escapeRegExp(value: string): string {
function extractMobileMediaBlocks(content: string): string {
const blocks: string[] = [];
const queryPattern = `${escapeRegExp(MOBILE_MEDIA_QUERY)}(?:\\s*,\\s*\\([^)]*\\))*`;
const queryPattern = `(?:${escapeRegExp(MOBILE_MEDIA_QUERY)}|\\(max-width:\\s*768px\\))(?:\\s*,\\s*\\([^)]*\\))*`;
const regex = new RegExp(`@media\\s*${queryPattern}\\s*\\{`, "g");
let match: RegExpExecArray | null;

View File

@@ -138,6 +138,7 @@ vi.mock("../../hooks/useConfirm", () => ({
}));
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
useViewportMode: () => "mobile",
}));
vi.mock("lucide-react", async (importOriginal) => {

View File

@@ -17,7 +17,8 @@ vi.mock("../../api", async (importOriginal) => {
vi.mock("../../hooks/useMemoryBackendStatus", () => ({
useMemoryBackendStatus: () => ({ status: null, capabilities: null, loading: false, error: null, refresh: vi.fn() }),
}));
vi.mock("../../hooks/useViewportMode", () => ({ useViewportMode: () => "desktop" }));
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", useViewportMode: () => "desktop" }));
vi.mock("../../hooks/useMobileKeyboard", () => ({
useMobileKeyboard: () => ({ keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false }),
}));

View File

@@ -31,7 +31,8 @@ vi.mock("../../hooks/useWorktrunkInstallStatus", () => ({
useWorktrunkInstallStatus: (...args: unknown[]) => mockUseWorktrunkInstallStatus(...args),
}));
vi.mock("../../hooks/useViewportMode", () => ({ useViewportMode: () => "desktop" }));
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", useViewportMode: () => "desktop" }));
vi.mock("../../hooks/useMobileKeyboard", () => ({
useMobileKeyboard: () => ({ keyboardOpen: false, keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0 }),
}));

View File

@@ -44,6 +44,7 @@ vi.mock("../../hooks/useMobileKeyboard", () => ({
}));
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
useViewportMode: () => "mobile",
}));

View File

@@ -506,18 +506,20 @@ describe("TaskReviewTab", () => {
expect(mobileMediaStart).toBeGreaterThanOrEqual(0);
const mobileCss = taskReviewCss.slice(mobileMediaStart);
const baseSummaryWrapRule = taskReviewCss.match(/\.task-review-tab__summary-wrap\s*\{[^}]*\}/)?.[0] ?? "";
const baseBodyRule = taskReviewCss.match(/\.task-review-tab__body\s*\{[^}]*\}/)?.[0] ?? "";
expect(baseSummaryWrapRule).toMatch(/flex\s*:\s*1\s+1\s+20rem\s*;/);
expect(baseSummaryWrapRule).not.toMatch(/flex\s*:\s*0\s+0\s+auto\s*;/);
expect(mobileCss).toMatch(/\.task-review-tab__header\s*\{[^}]*flex-direction\s*:\s*column\s*;[^}]*\}/);
expect(mobileCss).toMatch(/\.task-review-tab__summary-wrap\s*\{[^}]*flex\s*:\s*0\s+0\s+auto\s*;[^}]*\}/);
expect(mobileCss).not.toMatch(/\.task-review-tab__summary-wrap\s*\{[^}]*flex\s*:\s*1\s+1\s+20rem\s*;[^}]*\}/);
expect(mobileCss).toMatch(/\.task-review-tab__summary-wrap,\s*\.task-review-tab__actions,\s*\.task-review-tab__auto-merge-control\s*\{[^}]*width\s*:\s*100%\s*;[^}]*\}/);
expect(mobileCss).toMatch(/\.task-review-tab__actions\s*\{[^}]*justify-content\s*:\s*flex-start\s*;[^}]*\}/);
expect(mobileCss).toMatch(/\.task-review-tab__actions\s+\.btn\s*\{[^}]*width\s*:\s*100%\s*;[^}]*\}/);
expect(mobileCss).toMatch(/\.task-review-tab__body\s*\{[^}]*padding\s*:\s*var\(--space-sm\)\s*;[^}]*\}/);
expect(mobileCss).not.toMatch(/\.task-review-tab__actions\s+\.btn\s*\{[^}]*flex\s*:\s*1\s*;[^}]*\}/);
expect(taskReviewCss).toMatch(/\.task-review-tab__body\s*\{[^}]*overflow-wrap\s*:\s*anywhere\s*;[^}]*overflow-x\s*:\s*auto\s*;[^}]*\}/);
expect(baseBodyRule).toMatch(/overflow-wrap\s*:\s*anywhere\s*;/);
expect(baseBodyRule).toMatch(/overflow-x\s*:\s*auto\s*;/);
expect(taskReviewCss).toMatch(/\.task-review-tab__item\s*\{[^}]*padding\s*:\s*var\(--card-padding\)\s*;[^}]*\}/);
});
@@ -772,6 +774,47 @@ describe("TaskReviewTab", () => {
await waitFor(() => expect(screen.getByTestId("task-review-auto-merge-effective-hint")).toHaveTextContent("Effective: Auto-merge off — frozen on entry to review"));
});
it("keeps the review tab visible when global auto-merge changes while task detail is open", async () => {
const inReviewTask = makeTask({ column: "in-review", autoMerge: undefined, reviewState: { source: "pull-request", items: [], addressing: [] } });
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: inReviewTask.reviewState, automationStatus: null, emptyMessage: null });
const { rerender } = render(
<TaskReviewTab
task={inReviewTask}
addToast={vi.fn()}
prAuthAvailable
onRequestCreatePr={vi.fn()}
autoMergeEnabled={false}
/>,
);
expect(await screen.findByRole("button", { name: "Refresh" })).toBeInTheDocument();
expect(screen.getByTestId("task-review-auto-merge-select")).toBeInTheDocument();
expect(screen.getByTestId("task-review-create-pr")).toBeInTheDocument();
expect(screen.getByTestId("task-review-auto-merge-effective-hint")).toHaveTextContent(
"Effective: Auto-merge off — frozen on entry to review",
);
rerender(
<TaskReviewTab
task={inReviewTask}
addToast={vi.fn()}
prAuthAvailable
onRequestCreatePr={vi.fn()}
autoMergeEnabled
/>,
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument();
expect(screen.getByTestId("task-review-auto-merge-select")).toBeInTheDocument();
expect(screen.queryByTestId("task-review-create-pr")).toBeNull();
expect(screen.getByTestId("task-review-auto-merge-effective-hint")).toHaveTextContent(
"Effective: Auto-merge on — frozen on entry to review",
);
});
});
it("reflects current per-task auto-merge selection", async () => {
const task = makeTask({ autoMerge: true, reviewState: { source: "pull-request", items: [], addressing: [] } });
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });

View File

@@ -18,6 +18,7 @@ vi.mock("../../hooks/useMobileKeyboard", () => ({
}));
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
useViewportMode: (...args: unknown[]) => mockUseViewportMode(...args),
}));

View File

@@ -0,0 +1,493 @@
import React, { useEffect, useState } from "react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import type { Settings, Task } from "@fusion/core";
import { Board } from "../Board";
import { PageErrorBoundary } from "../ErrorBoundary";
import { TaskReviewTab } from "../TaskReviewTab";
import { RetryWarningProvider } from "../../context/RetryWarningContext";
import { useAppSettings } from "../../hooks/useAppSettings";
import { MOBILE_MEDIA_QUERY } from "../../hooks/useViewportMode";
import { fetchConfig, fetchSettings, fetchTaskReview, updateSettings } from "../../api";
const defaultSettings: Settings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: true,
recycleWorktrees: false,
worktreeInitCommand: "",
testCommand: "",
buildCommand: "",
capacityRiskBannerEnabled: false,
capacityRiskTodoThreshold: 20,
experimentalFeatures: {},
};
let mockSettings: Settings = { ...defaultSettings };
vi.mock("../../api", async (importOriginal) => {
const { createDashboardApiMock } = await import("../../test/mockApi");
return createDashboardApiMock(() => importOriginal<typeof import("../../api")>(), {
fetchConfig: vi.fn(() => Promise.resolve({ maxConcurrent: 2, rootDir: "/workspace/project" })),
fetchSettings: vi.fn(() => Promise.resolve({ ...mockSettings })),
updateSettings: vi.fn((updates: Partial<Settings>) => {
mockSettings = { ...mockSettings, ...updates };
return Promise.resolve({ ...mockSettings });
}),
fetchWorkflowSteps: vi.fn(() => Promise.resolve([])),
fetchAgents: vi.fn(() => Promise.resolve([])),
fetchTaskReview: vi.fn(() =>
Promise.resolve({
reviewState: { source: "pull-request", items: [], addressing: [] },
automationStatus: null,
emptyMessage: null,
}),
),
});
});
vi.mock("../../hooks/useBlockerFanout", () => ({
useBlockerFanout: () => new Map(),
}));
vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: vi.fn() }),
}));
vi.mock("../../hooks/useFlashOnIncrease", () => ({
useFlashOnIncrease: () => false,
}));
vi.mock("../../hooks/useBadgeWebSocket", () => ({
useBadgeWebSocket: () => ({
badgeUpdates: new Map(),
subscribeToBadge: vi.fn(),
unsubscribeFromBadge: vi.fn(),
isConnected: false,
}),
}));
vi.mock("../../hooks/useTaskDiffStats", () => ({
useTaskDiffStats: () => ({ stats: null, loading: false }),
}));
vi.mock("../../hooks/useAgentsMapCache", () => ({
useAgentsMapCache: () => ({ agentsMap: new Map(), agents: [], loading: false, refresh: vi.fn() }),
}));
vi.mock("../PluginSlot", () => ({
PluginSlot: () => null,
}));
function ensureMatchMedia() {
if (!window.matchMedia) {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn(),
});
}
}
function mockViewport(width: number, height = 812) {
ensureMatchMedia();
Object.defineProperty(window, "innerWidth", { value: width, configurable: true });
Object.defineProperty(window, "innerHeight", { value: height, configurable: true });
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches:
query === MOBILE_MEDIA_QUERY
? width <= 768 || height <= 480
: query === "(min-width: 769px) and (max-width: 1024px)"
? width >= 769 && width <= 1024
: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
function createVisualViewport(scale = 1) {
const resizeListeners = new Set<() => void>();
return {
scale,
offsetTop: 0,
height: 812,
addEventListener: vi.fn((event: string, listener: () => void) => {
if (event === "resize") {
resizeListeners.add(listener);
}
}),
removeEventListener: vi.fn((event: string, listener: () => void) => {
if (event === "resize") {
resizeListeners.delete(listener);
}
}),
dispatchResize: () => {
for (const listener of [...resizeListeners]) {
listener();
}
},
};
}
function installAnimationFrame() {
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
setTimeout(() => cb(0), 0);
return 1;
});
vi.stubGlobal("cancelAnimationFrame", vi.fn());
}
function createTask(id: string, column: Task["column"], overrides: Partial<Task> = {}): Task {
return {
id,
title: id,
description: `${id} description`,
column,
status: column === "in-review" ? "in-review" : overrides.status,
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-06-03T00:00:00.000Z",
updatedAt: "2026-06-03T00:00:00.000Z",
...overrides,
} as Task;
}
function SettingsBoardHarness({ tasks, openTaskOnMountId }: { tasks: Task[]; openTaskOnMountId?: string }) {
const { autoMerge, toggleAutoMerge, maxConcurrent } = useAppSettings("proj_123");
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const handleOpenDetail = (task: Task) => setSelectedTask(task);
useEffect(() => {
if (!openTaskOnMountId) {
return;
}
const initialTask = tasks.find((task) => task.id === openTaskOnMountId) ?? null;
if (initialTask) {
handleOpenDetail(initialTask);
}
}, [openTaskOnMountId, tasks]);
return (
<RetryWarningProvider value={undefined}>
<PageErrorBoundary>
<Board
tasks={tasks}
projectId="proj_123"
maxConcurrent={maxConcurrent}
onMoveTask={vi.fn(async () => ({} as Task))}
onOpenDetail={handleOpenDetail}
addToast={vi.fn()}
onQuickCreate={vi.fn(async () => undefined)}
onNewTask={vi.fn()}
autoMerge={autoMerge}
onToggleAutoMerge={toggleAutoMerge}
globalPaused={false}
prAuthAvailable={true}
/>
{selectedTask ? (
<div data-testid="task-detail-review-surface">
<TaskReviewTab task={selectedTask} addToast={vi.fn()} autoMergeEnabled={autoMerge} prAuthAvailable />
</div>
) : null}
</PageErrorBoundary>
</RetryWarningProvider>
);
}
function expectBoardVisible(taskTitles: string[] = []) {
expect(document.querySelector("main.board")).not.toBeNull();
expect(screen.getByText("In Review")).toBeInTheDocument();
for (const title of taskTitles) {
expect(screen.getAllByText(title).length).toBeGreaterThan(0);
}
expect(screen.queryByText("Something went wrong")).toBeNull();
}
function createInReviewAndWorktreeTasks() {
return [
createTask("FN-5972", "in-review"),
createTask("FN-5972-WT", "in-progress", {
title: "Worktree child task",
status: "in-progress",
worktree: "/workspace/project/.worktrees/FN-5972-WT",
}),
];
}
function renderBoardHarness({
width,
height = 812,
tasks,
autoMerge = false,
openTaskOnMountId,
}: {
width: number;
height?: number;
tasks: Task[];
autoMerge?: Settings["autoMerge"];
openTaskOnMountId?: string;
}) {
const viewportSpy = mockViewport(width, height);
const visualViewport = createVisualViewport();
Object.defineProperty(window, "visualViewport", {
configurable: true,
value: visualViewport,
});
installAnimationFrame();
mockSettings = { ...defaultSettings, autoMerge };
render(<SettingsBoardHarness tasks={tasks} openTaskOnMountId={openTaskOnMountId} />);
return { viewportSpy, visualViewport };
}
describe("auto-merge toggle mobile integration regression", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
mockSettings = { ...defaultSettings };
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("keeps the real board/task-card and worktree-group composition visible on mobile portrait after toggling auto-merge on and back off", async () => {
const { viewportSpy, visualViewport } = renderBoardHarness({
width: 375,
tasks: createInReviewAndWorktreeTasks(),
autoMerge: undefined,
});
await act(async () => {
await Promise.resolve();
});
expect(fetchConfig).toHaveBeenCalledWith("proj_123");
expect(fetchSettings).toHaveBeenCalledWith("proj_123");
act(() => {
vi.advanceTimersByTime(1);
});
expectBoardVisible(["FN-5972", "Worktree child task"]);
expect(screen.getAllByText("FN-5972-WT").length).toBeGreaterThan(0);
const toggle = screen.getByRole("checkbox", { name: "Auto-merge" });
expect(toggle).not.toBeChecked();
expect(screen.getByRole("button", { name: /create pull request/i })).toBeInTheDocument();
await act(async () => {
fireEvent.click(toggle);
await Promise.resolve();
});
act(() => {
visualViewport.dispatchResize();
vi.advanceTimersByTime(1);
});
expect(updateSettings).toHaveBeenCalledWith({ autoMerge: true }, "proj_123");
expect(toggle).toBeChecked();
expect(screen.queryByRole("button", { name: /create pull request/i })).toBeNull();
expectBoardVisible(["FN-5972", "Worktree child task"]);
await act(async () => {
fireEvent.click(toggle);
await Promise.resolve();
});
act(() => {
visualViewport.dispatchResize();
vi.advanceTimersByTime(1);
});
expect(updateSettings).toHaveBeenLastCalledWith({ autoMerge: false }, "proj_123");
expect(toggle).not.toBeChecked();
expect(screen.getByRole("button", { name: /create pull request/i })).toBeInTheDocument();
expectBoardVisible(["FN-5972", "Worktree child task"]);
viewportSpy.mockRestore();
});
it("keeps the board visible for an empty in-review column while round-tripping auto-merge on mobile", async () => {
const { viewportSpy, visualViewport } = renderBoardHarness({
width: 375,
tasks: [],
autoMerge: true,
});
await act(async () => {
await Promise.resolve();
});
act(() => {
vi.advanceTimersByTime(1);
});
const toggle = screen.getByRole("checkbox", { name: "Auto-merge" });
expect(toggle).toBeChecked();
expectBoardVisible();
expect(screen.getAllByText("No tasks").length).toBeGreaterThan(0);
await act(async () => {
fireEvent.click(toggle);
await Promise.resolve();
});
act(() => {
visualViewport.dispatchResize();
vi.advanceTimersByTime(1);
});
expect(toggle).not.toBeChecked();
expectBoardVisible();
await act(async () => {
fireEvent.click(toggle);
await Promise.resolve();
});
act(() => {
visualViewport.dispatchResize();
vi.advanceTimersByTime(1);
});
expect(toggle).toBeChecked();
expectBoardVisible();
viewportSpy.mockRestore();
});
it.each([
{ name: "mobile landscape", width: 844, height: 390 },
{ name: "tablet", width: 834, height: 1112 },
{ name: "desktop", width: 1280, height: 900 },
])("keeps the board visible after toggling auto-merge on $name", async ({ width, height }) => {
const { viewportSpy, visualViewport } = renderBoardHarness({
width,
height,
tasks: [createTask(`FN-${width}`, "in-review")],
autoMerge: false,
});
await act(async () => {
await Promise.resolve();
});
act(() => {
vi.advanceTimersByTime(1);
});
const toggle = screen.getByRole("checkbox", { name: "Auto-merge" });
expect(toggle).not.toBeChecked();
expectBoardVisible([`FN-${width}`]);
await act(async () => {
fireEvent.click(toggle);
await Promise.resolve();
});
act(() => {
visualViewport.dispatchResize();
vi.advanceTimersByTime(1);
});
expect(toggle).toBeChecked();
expectBoardVisible([`FN-${width}`]);
viewportSpy.mockRestore();
});
it("keeps task review detail visible while toggling auto-merge with a detail panel open", async () => {
const detailTask = createTask("FN-DETAIL", "in-review", {
reviewState: { source: "pull-request", items: [], addressing: [] },
});
vi.mocked(fetchTaskReview).mockResolvedValue({
reviewState: detailTask.reviewState ?? { source: "pull-request", items: [], addressing: [] },
automationStatus: null,
emptyMessage: null,
} as never);
const { viewportSpy, visualViewport } = renderBoardHarness({
width: 375,
tasks: [detailTask],
autoMerge: false,
openTaskOnMountId: detailTask.id,
});
await act(async () => {
await Promise.resolve();
});
act(() => {
vi.advanceTimersByTime(1);
});
await act(async () => {
await Promise.resolve();
});
expect(screen.getByTestId("task-detail-review-surface")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument();
expect(screen.getByTestId("task-review-auto-merge-effective-hint")).toHaveTextContent(
"Effective: Auto-merge off",
);
await act(async () => {
fireEvent.click(screen.getByRole("checkbox", { name: "Auto-merge" }));
await Promise.resolve();
});
act(() => {
visualViewport.dispatchResize();
vi.advanceTimersByTime(1);
});
expect(screen.getByTestId("task-detail-review-surface")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument();
expect(screen.getByTestId("task-review-auto-merge-effective-hint")).toHaveTextContent(
"Effective: Auto-merge on",
);
expectBoardVisible(["FN-DETAIL"]);
viewportSpy.mockRestore();
});
it("rolls back the real useAppSettings toggle on mobile without blanking the board when updateSettings fails", async () => {
vi.mocked(updateSettings).mockRejectedValueOnce(new Error("network"));
const { viewportSpy, visualViewport } = renderBoardHarness({
width: 375,
tasks: [createTask("FN-ROLLBACK", "in-review")],
autoMerge: false,
});
await act(async () => {
await Promise.resolve();
});
act(() => {
vi.advanceTimersByTime(1);
});
const toggle = screen.getByRole("checkbox", { name: "Auto-merge" });
expect(toggle).not.toBeChecked();
expectBoardVisible(["FN-ROLLBACK"]);
await act(async () => {
fireEvent.click(toggle);
await Promise.resolve();
});
act(() => {
visualViewport.dispatchResize();
vi.advanceTimersByTime(1);
});
expect(updateSettings).toHaveBeenCalledWith({ autoMerge: true }, "proj_123");
expect(toggle).not.toBeChecked();
expect(screen.getByRole("button", { name: /create pull request/i })).toBeInTheDocument();
expectBoardVisible(["FN-ROLLBACK"]);
viewportSpy.mockRestore();
});
});

View File

@@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { Board } from "../Board";
import { PageErrorBoundary } from "../ErrorBoundary";
import { MOBILE_MEDIA_QUERY } from "../../hooks/useViewportMode";
import type { Task } from "@fusion/core";
vi.mock("../../api", () => ({
@@ -54,11 +55,12 @@ function ensureMatchMedia() {
}
}
function mockViewport(width: number) {
function mockViewport(width: number, height = 812) {
ensureMatchMedia();
Object.defineProperty(window, "innerWidth", { value: width, configurable: true });
Object.defineProperty(window, "innerHeight", { value: height, configurable: true });
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches: query === "(max-width: 768px)" ? width <= 768 : false,
matches: query === MOBILE_MEDIA_QUERY ? width <= 768 || height <= 480 : false,
media: query,
onchange: null,
addListener: vi.fn(),

View File

@@ -288,6 +288,7 @@ vi.mock("../../hooks/useNodes", () => ({
const mockUseViewportMode = vi.fn(() => "desktop");
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
useViewportMode: (..._args: unknown[]) => mockUseViewportMode(..._args),
getViewportMode: () => "desktop",
}));

View File

@@ -389,7 +389,7 @@ describe("useChatRooms", () => {
expect(mockPostChatRoomMessage).not.toHaveBeenCalledWith("room-1", expect.objectContaining({ content: "hello" }), "proj-1");
});
it("rejects with original error when post fails and recovery transcript has no persisted user message", async () => {
it("rejects with original error while preserving recovered replies when post fails without a persisted user message", async () => {
const active = room("room-1", "one", "2026-05-09T01:00:00.000Z");
mockFetchChatRooms.mockResolvedValueOnce({ rooms: [active] });
const { result } = renderHook(() => useChatRooms("proj-1"));
@@ -400,9 +400,14 @@ describe("useChatRooms", () => {
act(() => result.current.selectRoom("room-1"));
await waitFor(() => expect(result.current.activeRoom?.id).toBe("room-1"));
const recoveredAssistantReply = {
...roomMessage("msg-assistant", "room-1", "Room reply"),
role: "assistant" as const,
senderAgentId: "agent-1",
};
mockPostChatRoomMessage.mockRejectedValueOnce(new Error("POST failed"));
mockFetchChatRoomMessages.mockResolvedValueOnce({
messages: [{ ...roomMessage("msg-assistant", "room-1", "Room reply"), role: "assistant", senderAgentId: "agent-1" }],
messages: [recoveredAssistantReply],
});
let postError: unknown;
@@ -418,7 +423,7 @@ describe("useChatRooms", () => {
expect((postError as Error).message).toBe("POST failed");
expect(postError).not.toBeInstanceOf(RoomMessageDeliveredButReplyFailedError);
expect(result.current.messages).toEqual([]);
expect(result.current.messages).toEqual([recoveredAssistantReply]);
});
it("uses desc order when refreshing after send failure", async () => {

View File

@@ -1,12 +1,33 @@
import { renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useViewportMode } from "../useViewportMode";
import { getViewportMode, MOBILE_MEDIA_QUERY, useViewportMode } from "../useViewportMode";
describe("useViewportMode", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("treats short landscape phones as mobile", () => {
vi.stubGlobal(
"matchMedia",
vi.fn((query: string) => ({
matches:
query === MOBILE_MEDIA_QUERY
? true
: query === "(min-width: 769px) and (max-width: 1024px)"
? false
: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
);
expect(getViewportMode()).toBe("mobile");
expect(renderHook(() => useViewportMode()).result.current).toBe("mobile");
});
it("supports legacy MediaQueryList listeners without runtime errors", () => {
const listeners: Array<() => void> = [];
const removeListener = vi.fn((listener: () => void) => {
@@ -18,7 +39,7 @@ describe("useViewportMode", () => {
vi.stubGlobal(
"matchMedia",
vi.fn((query: string) => ({
matches: query === "(max-width: 768px)",
matches: query === MOBILE_MEDIA_QUERY,
media: query,
onchange: null,
addListener: (listener: () => void) => listeners.push(listener),

View File

@@ -6,7 +6,7 @@ export type ViewportMode = "mobile" | "tablet" | "desktop";
// 768 CSS px wide but stay short. Without it, landscape phones fall out of
// mobile mode and lose the bottom nav bar + get the desktop horizontally-
// scrollable board.
export const MOBILE_MEDIA_QUERY = "(max-width: 768px)";
export const MOBILE_MEDIA_QUERY = "(max-width: 768px), (max-height: 480px)";
export function getViewportMode(): ViewportMode {
if (typeof window === "undefined") return "desktop";

View File

@@ -92,7 +92,7 @@ const qualityAppFoundationUiTests = [
const qualityAppHooksAndUtilsTests = [
// Hooks and utilities are fast, user-visible state/formatting behavior.
"app/context/**/*.test.tsx",
"app/hooks/__tests__/{useAgents,useAgentLogs,useAgentLogs.resume-instrumentation,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodes.resume-instrumentation,useNodeSettingsSync,useProjects,useProjects.resume-instrumentation,useMeshState.resume-instrumentation,useManagedDockerNodes.resume-instrumentation,usePrChecksStream.resume-instrumentation,useDevServerLogs.resume-instrumentation,useResearch.resume-instrumentation,useBackgroundSessions.resume-instrumentation,useQuickChat,useTasks,useTasks.resume-instrumentation,useChatRooms.resume-instrumentation,useTerminalSessions,useTheme,useToast,useUsageData,useViewState,useMergeAdvanceNotice}.test.{ts,tsx}",
"app/hooks/__tests__/{useAgents,useAgentLogs,useAgentLogs.resume-instrumentation,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodes.resume-instrumentation,useNodeSettingsSync,useProjects,useProjects.resume-instrumentation,useMeshState.resume-instrumentation,useManagedDockerNodes.resume-instrumentation,usePrChecksStream.resume-instrumentation,useDevServerLogs.resume-instrumentation,useResearch.resume-instrumentation,useBackgroundSessions.resume-instrumentation,useQuickChat,useTasks,useTasks.resume-instrumentation,useChatRooms,useTerminalSessions,useTheme,useToast,useUsageData,useViewportMode,useViewState,useMergeAdvanceNotice}.test.{ts,tsx}",
"app/utils/**/*.test.{ts,tsx}",
];
@@ -108,6 +108,8 @@ const qualityAppComponentTests = [
"AuthTokenRecoveryDialog",
"Board",
"Board.canDropTask",
"auto-merge-toggle-blank.mobile",
"auto-merge-toggle-blank.mobile-integration",
"board-mobile",
"board-mobile-view-switch",
"BranchGroupCard",