feat(FN-816): improve mobile UI theme, navigation, and changed-files viewer
- Fix modal entry flow, navigation, and reset behavior for mobile - Apply mobile theme fixes and navigation improvements across dashboard - Enhance ChangedFilesModal with mobile-friendly behavior and responsive layout - Add comprehensive tests for ChangedFilesModal mobile interactions - Fix orphaned pause path in executor and remove unused code - Update dashboard README with mobile changed-files viewer documentation - Add mobile-specific CSS styles for improved responsiveness - Remove stale changeset for orphaned pause path fix
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
||||
import {
|
||||
FileEdit,
|
||||
FileMinus,
|
||||
@@ -79,6 +79,10 @@ export function ChangedFilesModal({
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [mobileView, setMobileView] = useState<"list" | "diff">("list");
|
||||
|
||||
// Track whether the user has manually navigated to the diff view on mobile.
|
||||
// This prevents the resize-to-mobile effect from stealing navigation intent.
|
||||
const mobileDiffIntentional = useRef(false);
|
||||
|
||||
// Detect mobile viewport
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -93,11 +97,13 @@ export function ChangedFilesModal({
|
||||
}, [isOpen]);
|
||||
|
||||
// When resizing from desktop to mobile with a file selected, show diff pane
|
||||
// only if the user hasn't just opened the modal (which starts at the list).
|
||||
// When resizing from mobile to desktop, no special action needed (both panes visible)
|
||||
useEffect(() => {
|
||||
if (!isOpen || !isMobile) return;
|
||||
// If we just became mobile and have a selected file, show diff
|
||||
if (selectedFile) {
|
||||
// Only auto-switch to diff on resize if the user is actively viewing a diff
|
||||
// (not the initial open where resetSelection has just cleared selectedFile)
|
||||
if (selectedFile && mobileDiffIntentional.current) {
|
||||
setMobileView("diff");
|
||||
}
|
||||
}, [isOpen, isMobile, selectedFile]);
|
||||
@@ -110,10 +116,12 @@ export function ChangedFilesModal({
|
||||
}
|
||||
}, [isOpen, isMobile, loading, files, selectedFile, setSelectedFile]);
|
||||
|
||||
// Reset mobile view and selection when modal opens
|
||||
// Reset mobile view and selection when modal opens.
|
||||
// Always start on the file list so mobile users see changed files first.
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setMobileView("list");
|
||||
mobileDiffIntentional.current = false;
|
||||
resetSelection();
|
||||
}
|
||||
}, [isOpen, resetSelection]);
|
||||
@@ -123,17 +131,23 @@ export function ChangedFilesModal({
|
||||
if (!isOpen) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
// On mobile diff view, Escape goes back to list first
|
||||
if (isMobile && mobileView === "diff") {
|
||||
setMobileView("list");
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
}, [isOpen, onClose, isMobile, mobileView]);
|
||||
|
||||
const handleSelectFile = useCallback(
|
||||
(file: TaskFileDiff) => {
|
||||
setSelectedFile(file);
|
||||
if (isMobile) {
|
||||
mobileDiffIntentional.current = true;
|
||||
setMobileView("diff");
|
||||
}
|
||||
},
|
||||
@@ -191,16 +205,25 @@ export function ChangedFilesModal({
|
||||
</div>
|
||||
|
||||
<div className="file-browser-body changed-files-layout">
|
||||
<aside className={sidebarClasses}>
|
||||
<aside className={sidebarClasses} aria-label="Changed files sidebar">
|
||||
{loading ? (
|
||||
<div className="gm-diff-loading">Loading changed files…</div>
|
||||
<div className="gm-diff-loading changed-files-loading" role="status">
|
||||
<span className="changed-files-loading-spinner" aria-hidden="true" />
|
||||
<span>Loading changed files…</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="gm-diff-error">{error}</div>
|
||||
<div className="gm-diff-error changed-files-error" role="alert">
|
||||
<span className="changed-files-error-icon" aria-hidden="true">⚠</span>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="file-browser-empty">No files changed</div>
|
||||
<div className="file-browser-empty changed-files-empty">
|
||||
<span className="changed-files-empty-icon" aria-hidden="true">📁</span>
|
||||
<span>No files changed</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="file-browser-list" role="list" aria-label="Changed files list">
|
||||
{files.map((file) => {
|
||||
{files.map((file, index) => {
|
||||
const active =
|
||||
selectedFile?.path === file.path && selectedFile?.oldPath === file.oldPath;
|
||||
return (
|
||||
@@ -209,6 +232,7 @@ export function ChangedFilesModal({
|
||||
type="button"
|
||||
role="listitem"
|
||||
aria-label={file.path}
|
||||
aria-current={active ? "true" : undefined}
|
||||
className={`file-node file-node--file changed-files-entry ${active ? "active" : ""}`}
|
||||
onClick={() => handleSelectFile(file)}
|
||||
>
|
||||
@@ -247,7 +271,7 @@ export function ChangedFilesModal({
|
||||
{getStatusLabel(selectedFile.status)}
|
||||
</span>
|
||||
{selectedFile.oldPath ? (
|
||||
<span>Renamed from {selectedFile.oldPath}</span>
|
||||
<span className="changed-files-renamed">Renamed from {selectedFile.oldPath}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,7 +283,9 @@ export function ChangedFilesModal({
|
||||
</div>
|
||||
</div>
|
||||
) : !loading && !error && files.length > 0 ? (
|
||||
<div className="file-browser-empty">Select a file to view changes</div>
|
||||
<div className="file-browser-empty changed-files-empty">
|
||||
Select a file to view changes
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { ChangedFilesModal } from "../ChangedFilesModal";
|
||||
import * as changedFilesHook from "../../hooks/useChangedFiles";
|
||||
|
||||
@@ -92,7 +92,7 @@ describe("ChangedFilesModal", () => {
|
||||
expect(screen.getByText("No files changed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes on Escape", () => {
|
||||
it("closes on Escape on desktop", () => {
|
||||
render(
|
||||
<ChangedFilesModal
|
||||
taskId="KB-651"
|
||||
@@ -203,6 +203,24 @@ describe("ChangedFilesModal", () => {
|
||||
expect(mockResetSelection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("marks the active file with aria-current", () => {
|
||||
render(
|
||||
<ChangedFilesModal
|
||||
taskId="KB-651"
|
||||
worktree="/repo/.worktrees/kb-651"
|
||||
column="in-progress"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
const activeItem = screen.getByRole("listitem", { name: "src/a.ts" });
|
||||
expect(activeItem).toHaveAttribute("aria-current", "true");
|
||||
|
||||
const inactiveItem = screen.getByRole("listitem", { name: /src\/b.ts/i });
|
||||
expect(inactiveItem).not.toHaveAttribute("aria-current");
|
||||
});
|
||||
|
||||
describe("mobile navigation", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(window, "innerWidth", "get").mockReturnValue(600);
|
||||
@@ -265,7 +283,63 @@ describe("ChangedFilesModal", () => {
|
||||
expect(mockSetSelectedFile).toHaveBeenCalledWith(defaultFiles[1]);
|
||||
});
|
||||
|
||||
it("shows back button on mobile when viewing diff", () => {
|
||||
it("shows back button on mobile when user selects a file", () => {
|
||||
// Start with no selected file so user sees the list
|
||||
mockUseChangedFiles.mockReturnValue({
|
||||
files: defaultFiles,
|
||||
loading: false,
|
||||
error: null,
|
||||
selectedFile: null,
|
||||
setSelectedFile: mockSetSelectedFile,
|
||||
resetSelection: mockResetSelection,
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<ChangedFilesModal
|
||||
taskId="KB-651"
|
||||
worktree="/repo/.worktrees/kb-651"
|
||||
column="in-progress"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
// No back button when on file list
|
||||
expect(screen.queryByLabelText("Back to file list")).not.toBeInTheDocument();
|
||||
|
||||
// Simulate user selecting a file - the hook will update selectedFile
|
||||
// and the component will set mobileView to "diff"
|
||||
fireEvent.click(screen.getByRole("listitem", { name: /src\/b.ts/i }));
|
||||
expect(mockSetSelectedFile).toHaveBeenCalledWith(defaultFiles[1]);
|
||||
|
||||
// Now simulate the hook providing the selected file (rerender with updated hook state)
|
||||
mockUseChangedFiles.mockReturnValue({
|
||||
files: defaultFiles,
|
||||
loading: false,
|
||||
error: null,
|
||||
selectedFile: defaultFiles[1],
|
||||
setSelectedFile: mockSetSelectedFile,
|
||||
resetSelection: mockResetSelection,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<ChangedFilesModal
|
||||
taskId="KB-651"
|
||||
worktree="/repo/.worktrees/kb-651"
|
||||
column="in-progress"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
// After user selects, back button should appear since mobileDiffIntentional was set
|
||||
const backButton = screen.queryByLabelText("Back to file list");
|
||||
expect(backButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does NOT show back button when hook provides selectedFile without user action", () => {
|
||||
// Simulate cached data where the hook already has a selected file
|
||||
// The modal should NOT auto-switch to diff on mobile without user intent
|
||||
mockUseChangedFiles.mockReturnValue({
|
||||
files: defaultFiles,
|
||||
loading: false,
|
||||
@@ -285,11 +359,9 @@ describe("ChangedFilesModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// When selectedFile is set, the mobile view should switch to diff
|
||||
// and show the back button. Since the hook returns selectedFile,
|
||||
// the component's isMobile+selectedFile effect will set mobileView to "diff"
|
||||
const backButton = screen.queryByLabelText("Back to file list");
|
||||
expect(backButton).toBeInTheDocument();
|
||||
// Without explicit user action, the back button should NOT appear
|
||||
// because the modal should show the file list first on mobile
|
||||
expect(screen.queryByLabelText("Back to file list")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show back button on desktop", () => {
|
||||
@@ -317,7 +389,31 @@ describe("ChangedFilesModal", () => {
|
||||
expect(screen.queryByLabelText("Back to file list")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows selected file path in header on mobile diff view", () => {
|
||||
it("shows selected file path in header on mobile diff view after user selects file", () => {
|
||||
// Start with no selection so user sees the list
|
||||
mockUseChangedFiles.mockReturnValue({
|
||||
files: defaultFiles,
|
||||
loading: false,
|
||||
error: null,
|
||||
selectedFile: null,
|
||||
setSelectedFile: mockSetSelectedFile,
|
||||
resetSelection: mockResetSelection,
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<ChangedFilesModal
|
||||
taskId="KB-651"
|
||||
worktree="/repo/.worktrees/kb-651"
|
||||
column="in-progress"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
// User selects a file
|
||||
fireEvent.click(screen.getByRole("listitem", { name: /src\/a.ts/i }));
|
||||
|
||||
// Simulate hook returning selected file
|
||||
mockUseChangedFiles.mockReturnValue({
|
||||
files: defaultFiles,
|
||||
loading: false,
|
||||
@@ -327,7 +423,7 @@ describe("ChangedFilesModal", () => {
|
||||
resetSelection: mockResetSelection,
|
||||
});
|
||||
|
||||
render(
|
||||
rerender(
|
||||
<ChangedFilesModal
|
||||
taskId="KB-651"
|
||||
worktree="/repo/.worktrees/kb-651"
|
||||
@@ -403,6 +499,161 @@ describe("ChangedFilesModal", () => {
|
||||
const diffPatch = document.querySelector(".gm-diff-patch");
|
||||
expect(diffPatch).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Escape on mobile diff view goes back to list instead of closing", () => {
|
||||
// Start with no selection so user sees the list
|
||||
mockUseChangedFiles.mockReturnValue({
|
||||
files: defaultFiles,
|
||||
loading: false,
|
||||
error: null,
|
||||
selectedFile: null,
|
||||
setSelectedFile: mockSetSelectedFile,
|
||||
resetSelection: mockResetSelection,
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<ChangedFilesModal
|
||||
taskId="KB-651"
|
||||
worktree="/repo/.worktrees/kb-651"
|
||||
column="in-progress"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
// User selects a file, triggering mobileView to "diff"
|
||||
fireEvent.click(screen.getByRole("listitem", { name: /src\/a.ts/i }));
|
||||
|
||||
// Simulate hook returning selected file (triggers diff view)
|
||||
mockUseChangedFiles.mockReturnValue({
|
||||
files: defaultFiles,
|
||||
loading: false,
|
||||
error: null,
|
||||
selectedFile: defaultSelectedFile,
|
||||
setSelectedFile: mockSetSelectedFile,
|
||||
resetSelection: mockResetSelection,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<ChangedFilesModal
|
||||
taskId="KB-651"
|
||||
worktree="/repo/.worktrees/kb-651"
|
||||
column="in-progress"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Now we should be in diff view with back button visible
|
||||
expect(screen.getByLabelText("Back to file list")).toBeInTheDocument();
|
||||
|
||||
// On mobile with diff view, Escape should go back to list, not close
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
|
||||
// Now pressing Escape again (on list view) should close the modal
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("starts on list view when modal opens on mobile", () => {
|
||||
// Simulate hook returning a selected file (e.g., cached from previous open)
|
||||
mockUseChangedFiles.mockReturnValue({
|
||||
files: defaultFiles,
|
||||
loading: false,
|
||||
error: null,
|
||||
selectedFile: defaultSelectedFile,
|
||||
setSelectedFile: mockSetSelectedFile,
|
||||
resetSelection: mockResetSelection,
|
||||
});
|
||||
|
||||
render(
|
||||
<ChangedFilesModal
|
||||
taskId="KB-651"
|
||||
worktree="/repo/.worktrees/kb-651"
|
||||
column="in-progress"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
// On first render after open, the resetSelection effect fires.
|
||||
// However, since the hook provides selectedFile, the mobile view
|
||||
// may auto-switch to diff. The important thing is resetSelection was called.
|
||||
expect(mockResetSelection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loading state has role=status for accessibility", () => {
|
||||
mockUseChangedFiles.mockReturnValue({
|
||||
files: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
selectedFile: null,
|
||||
setSelectedFile: mockSetSelectedFile,
|
||||
resetSelection: mockResetSelection,
|
||||
});
|
||||
|
||||
render(
|
||||
<ChangedFilesModal
|
||||
taskId="KB-651"
|
||||
worktree="/repo/.worktrees/kb-651"
|
||||
column="in-progress"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
const loadingEl = screen.getByRole("status");
|
||||
expect(loadingEl).toHaveTextContent("Loading changed files…");
|
||||
});
|
||||
|
||||
it("error state has role=alert for accessibility", () => {
|
||||
mockUseChangedFiles.mockReturnValue({
|
||||
files: [],
|
||||
loading: false,
|
||||
error: "Network error",
|
||||
selectedFile: null,
|
||||
setSelectedFile: mockSetSelectedFile,
|
||||
resetSelection: mockResetSelection,
|
||||
});
|
||||
|
||||
render(
|
||||
<ChangedFilesModal
|
||||
taskId="KB-651"
|
||||
worktree="/repo/.worktrees/kb-651"
|
||||
column="in-progress"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
const alertEl = screen.getByRole("alert");
|
||||
expect(alertEl).toHaveTextContent("Network error");
|
||||
});
|
||||
|
||||
it("sidebar has aria-label for accessibility", () => {
|
||||
mockUseChangedFiles.mockReturnValue({
|
||||
files: defaultFiles,
|
||||
loading: false,
|
||||
error: null,
|
||||
selectedFile: null,
|
||||
setSelectedFile: mockSetSelectedFile,
|
||||
resetSelection: mockResetSelection,
|
||||
});
|
||||
|
||||
render(
|
||||
<ChangedFilesModal
|
||||
taskId="KB-651"
|
||||
worktree="/repo/.worktrees/kb-651"
|
||||
column="in-progress"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
const sidebar = document.querySelector(".changed-files-sidebar");
|
||||
expect(sidebar).toHaveAttribute("aria-label", "Changed files sidebar");
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktop layout", () => {
|
||||
|
||||
Reference in New Issue
Block a user