diff --git a/.changeset/fn-5972-mobile-auto-merge.md b/.changeset/fn-5972-mobile-auto-merge.md new file mode 100644 index 0000000000..14cbde8b27 --- /dev/null +++ b/.changeset/fn-5972-mobile-auto-merge.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix the dashboard mobile auto-merge toggle blank-screen regression by restoring shared mobile breakpoint coverage and strengthening the regression suite across mobile, tablet, desktop, rollback, and task-review detail surfaces. diff --git a/packages/cli/src/commands/__tests__/project.test.ts b/packages/cli/src/commands/__tests__/project.test.ts index 9d62b0d381..5fc892ef39 100644 --- a/packages/cli/src/commands/__tests__/project.test.ts +++ b/packages/cli/src/commands/__tests__/project.test.ts @@ -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"); diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 9b7ed08a90..d67044a63a 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -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; } diff --git a/packages/dashboard/app/components/__tests__/AgentsView.orgchart.test.tsx b/packages/dashboard/app/components/__tests__/AgentsView.orgchart.test.tsx index fb57c2767d..bb22a958a9 100644 --- a/packages/dashboard/app/components/__tests__/AgentsView.orgchart.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentsView.orgchart.test.tsx @@ -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" })); diff --git a/packages/dashboard/app/components/__tests__/AgentsView.test.tsx b/packages/dashboard/app/components/__tests__/AgentsView.test.tsx index 8227c6f179..dae2a8fb4a 100644 --- a/packages/dashboard/app/components/__tests__/AgentsView.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentsView.test.tsx @@ -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(), })); diff --git a/packages/dashboard/app/components/__tests__/App.test.tsx b/packages/dashboard/app/components/__tests__/App.test.tsx index 357f7648cb..fba629552b 100644 --- a/packages/dashboard/app/components/__tests__/App.test.tsx +++ b/packages/dashboard/app/components/__tests__/App.test.tsx @@ -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(); }); diff --git a/packages/dashboard/app/components/__tests__/ChatView.swipe-back.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.swipe-back.test.tsx index 113920edf5..9b09fcf8f7 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.swipe-back.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.swipe-back.test.tsx @@ -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(), diff --git a/packages/dashboard/app/components/__tests__/ChatView.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.test.tsx index a92bf12ba5..112450cd08 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.test.tsx @@ -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(), diff --git a/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx b/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx index e820204d97..3a6d47693b 100644 --- a/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx @@ -19,6 +19,7 @@ const mockUseMobileKeyboard = vi.fn(() => ({ })); vi.mock("../../hooks/useViewportMode", () => ({ + MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", useViewportMode: () => mockUseViewportMode(), })); diff --git a/packages/dashboard/app/components/__tests__/MailboxView.test.tsx b/packages/dashboard/app/components/__tests__/MailboxView.test.tsx index aff7a6c535..79bfe204f7 100644 --- a/packages/dashboard/app/components/__tests__/MailboxView.test.tsx +++ b/packages/dashboard/app/components/__tests__/MailboxView.test.tsx @@ -28,6 +28,7 @@ vi.mock("../../api", () => ({ })); vi.mock("../../hooks/useViewportMode", () => ({ + MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", useViewportMode: vi.fn(), })); diff --git a/packages/dashboard/app/components/__tests__/MilestoneSliceInterviewModal.test.tsx b/packages/dashboard/app/components/__tests__/MilestoneSliceInterviewModal.test.tsx index 76b21cd3dc..095766dd57 100644 --- a/packages/dashboard/app/components/__tests__/MilestoneSliceInterviewModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/MilestoneSliceInterviewModal.test.tsx @@ -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", () => ({ diff --git a/packages/dashboard/app/components/__tests__/MissionManager.swipe-back.test.tsx b/packages/dashboard/app/components/__tests__/MissionManager.swipe-back.test.tsx index 40391ceab8..4d0105053a 100644 --- a/packages/dashboard/app/components/__tests__/MissionManager.swipe-back.test.tsx +++ b/packages/dashboard/app/components/__tests__/MissionManager.swipe-back.test.tsx @@ -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(), })); diff --git a/packages/dashboard/app/components/__tests__/NewTaskModal.shared-cache.test.tsx b/packages/dashboard/app/components/__tests__/NewTaskModal.shared-cache.test.tsx index 4d3bedd0cb..a559e152e4 100644 --- a/packages/dashboard/app/components/__tests__/NewTaskModal.shared-cache.test.tsx +++ b/packages/dashboard/app/components/__tests__/NewTaskModal.shared-cache.test.tsx @@ -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() { let resolve!: (value: T) => void; diff --git a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx index 3d76c9f86c..a9c797bdbb 100644 --- a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx @@ -52,6 +52,7 @@ vi.mock("../../hooks/useMobileKeyboard", () => ({ })); vi.mock("../../hooks/useViewportMode", () => ({ + MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", useViewportMode: () => "mobile", })); diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.autosize.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.autosize.test.tsx index 9cf5c14689..fa9f47a30c 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.autosize.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.autosize.test.tsx @@ -78,6 +78,7 @@ vi.mock("../../hooks/useConfirm", () => ({ })); vi.mock("../../hooks/useViewportMode", () => ({ + MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", useViewportMode: () => mockUseViewportMode(), })); diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.favorites.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.favorites.test.tsx index 0895fb4775..d37a76aa47 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.favorites.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.favorites.test.tsx @@ -100,6 +100,7 @@ vi.mock("../../hooks/useConfirm", () => ({ })); vi.mock("../../hooks/useViewportMode", () => ({ + MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", useViewportMode: () => mockUseViewportMode(), })); diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.initial.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.initial.test.tsx index d9393080d8..9a8bcea21a 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.initial.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.initial.test.tsx @@ -110,6 +110,7 @@ vi.mock("../../hooks/useConfirm", () => ({ })); vi.mock("../../hooks/useViewportMode", () => ({ + MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", useViewportMode: () => mockUseViewportMode(), })); diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx index cf03208a2d..c7108a91bf 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx @@ -114,6 +114,7 @@ vi.mock("../../hooks/useConfirm", () => ({ })); vi.mock("../../hooks/useViewportMode", () => ({ + MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", useViewportMode: () => mockUseViewportMode(), })); diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.questions.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.questions.test.tsx index aba8fd3a67..7c74323d58 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.questions.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.questions.test.tsx @@ -101,6 +101,7 @@ vi.mock("../../hooks/useConfirm", () => ({ })); vi.mock("../../hooks/useViewportMode", () => ({ + MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", useViewportMode: () => mockUseViewportMode(), })); diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.swipe-back.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.swipe-back.test.tsx index e6a92e7975..89458b5a1d 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.swipe-back.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.swipe-back.test.tsx @@ -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(), })); diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.ui-interactions.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.ui-interactions.test.tsx index 868fc60362..ec9b0b29f7 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.ui-interactions.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.ui-interactions.test.tsx @@ -111,6 +111,7 @@ vi.mock("../../hooks/useConfirm", () => ({ })); vi.mock("../../hooks/useViewportMode", () => ({ + MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", useViewportMode: () => mockUseViewportMode(), })); diff --git a/packages/dashboard/app/components/__tests__/QuickChatFAB.autosize.test.tsx b/packages/dashboard/app/components/__tests__/QuickChatFAB.autosize.test.tsx index 72a9f187eb..4a261c1cee 100644 --- a/packages/dashboard/app/components/__tests__/QuickChatFAB.autosize.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickChatFAB.autosize.test.tsx @@ -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"), })); diff --git a/packages/dashboard/app/components/__tests__/QuickChatFAB.shared-cache.test.tsx b/packages/dashboard/app/components/__tests__/QuickChatFAB.shared-cache.test.tsx index b5bd37e5c9..4d368b6923 100644 --- a/packages/dashboard/app/components/__tests__/QuickChatFAB.shared-cache.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickChatFAB.shared-cache.test.tsx @@ -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() { diff --git a/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx b/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx index 3f0d740233..0b0ac4364e 100644 --- a/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx +++ b/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx @@ -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() })); diff --git a/packages/dashboard/app/components/__tests__/SecretsView.mobile.test.tsx b/packages/dashboard/app/components/__tests__/SecretsView.mobile.test.tsx index 9ca5fb8652..369b76ba49 100644 --- a/packages/dashboard/app/components/__tests__/SecretsView.mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/SecretsView.mobile.test.tsx @@ -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; diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx index ae149f6cbd..7388e59606 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx @@ -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) => { diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.testMode.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.testMode.test.tsx index 95210e0f37..2719239648 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.testMode.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.testMode.test.tsx @@ -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 }), })); diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.worktrunk.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.worktrunk.test.tsx index b02f664c04..25862e3459 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.worktrunk.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.worktrunk.test.tsx @@ -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 }), })); diff --git a/packages/dashboard/app/components/__tests__/SubtaskBreakdownModal.test.tsx b/packages/dashboard/app/components/__tests__/SubtaskBreakdownModal.test.tsx index 3f9198365e..6bc29ef31a 100644 --- a/packages/dashboard/app/components/__tests__/SubtaskBreakdownModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SubtaskBreakdownModal.test.tsx @@ -44,6 +44,7 @@ vi.mock("../../hooks/useMobileKeyboard", () => ({ })); vi.mock("../../hooks/useViewportMode", () => ({ + MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", useViewportMode: () => "mobile", })); diff --git a/packages/dashboard/app/components/__tests__/TaskReviewTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskReviewTab.test.tsx index e648ee7d06..a5e78f9e07 100644 --- a/packages/dashboard/app/components/__tests__/TaskReviewTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskReviewTab.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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 }); diff --git a/packages/dashboard/app/components/__tests__/TodoModal.test.tsx b/packages/dashboard/app/components/__tests__/TodoModal.test.tsx index be776e4270..e53eb3d02a 100644 --- a/packages/dashboard/app/components/__tests__/TodoModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TodoModal.test.tsx @@ -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), })); diff --git a/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile-integration.test.tsx b/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile-integration.test.tsx new file mode 100644 index 0000000000..ef2aa46dd1 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile-integration.test.tsx @@ -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(), { + fetchConfig: vi.fn(() => Promise.resolve({ maxConcurrent: 2, rootDir: "/workspace/project" })), + fetchSettings: vi.fn(() => Promise.resolve({ ...mockSettings })), + updateSettings: vi.fn((updates: Partial) => { + 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 { + 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(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 ( + + + ({} as Task))} + onOpenDetail={handleOpenDetail} + addToast={vi.fn()} + onQuickCreate={vi.fn(async () => undefined)} + onNewTask={vi.fn()} + autoMerge={autoMerge} + onToggleAutoMerge={toggleAutoMerge} + globalPaused={false} + prAuthAvailable={true} + /> + {selectedTask ? ( +
+ +
+ ) : null} +
+
+ ); +} + +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(); + + 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(); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile.test.tsx b/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile.test.tsx index b639379313..c34b47bcf9 100644 --- a/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile.test.tsx @@ -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(), diff --git a/packages/dashboard/app/components/__tests__/navigation-history.test.tsx b/packages/dashboard/app/components/__tests__/navigation-history.test.tsx index 70c3f5a188..d580fe1a64 100644 --- a/packages/dashboard/app/components/__tests__/navigation-history.test.tsx +++ b/packages/dashboard/app/components/__tests__/navigation-history.test.tsx @@ -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", })); diff --git a/packages/dashboard/app/hooks/__tests__/useChatRooms.test.ts b/packages/dashboard/app/hooks/__tests__/useChatRooms.test.ts index 789dc4b4ab..c39ad95856 100644 --- a/packages/dashboard/app/hooks/__tests__/useChatRooms.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useChatRooms.test.ts @@ -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 () => { diff --git a/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts b/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts index cdf37821e7..81c00718fe 100644 --- a/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useViewportMode.test.ts @@ -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), diff --git a/packages/dashboard/app/hooks/useViewportMode.ts b/packages/dashboard/app/hooks/useViewportMode.ts index 708c1d761a..593e2a7bec 100644 --- a/packages/dashboard/app/hooks/useViewportMode.ts +++ b/packages/dashboard/app/hooks/useViewportMode.ts @@ -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"; diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index 19b610ad32..739294f06f 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -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", diff --git a/plugins/fusion-plugin-acp-runtime/src/process-manager.ts b/plugins/fusion-plugin-acp-runtime/src/process-manager.ts index d55fbc23cd..8638c48a87 100644 --- a/plugins/fusion-plugin-acp-runtime/src/process-manager.ts +++ b/plugins/fusion-plugin-acp-runtime/src/process-manager.ts @@ -1,4 +1,4 @@ -// port-4040-allowlist: doc comments below reference the "never kill port 4040" rule; no kill targets it. +// port-4040-allowlist: this file documents the reserved dashboard port in kill-guard comments only; no kill targets it. // Subprocess lifecycle for the ACP runtime. // // Mirrors the hardening conventions in