feat(web): mobile file viewer with desktop parity (#945)

This commit is contained in:
Luan Martins
2026-05-19 09:18:22 +01:00
committed by GitHub
parent 59ea5b7106
commit 663fa6f69c
6 changed files with 645 additions and 27 deletions
@@ -5,13 +5,15 @@ import { vscodeDark } from "@uiw/codemirror-theme-vscode";
import { EditorView } from "@codemirror/view";
import type { Extension } from "@codemirror/state";
import { getCodeMirrorExtensionFromPath } from "@/lib/languages";
import { cn } from "@/lib/utils";
type FileViewerContentProps = {
path: string;
content: string;
className?: string;
};
export function FileViewerContent({ path, content }: FileViewerContentProps) {
export function FileViewerContent({ path, content, className }: FileViewerContentProps) {
const langExt = getCodeMirrorExtensionFromPath(path);
const extensions: Extension[] = [EditorView.lineWrapping, EditorView.editable.of(false)];
if (langExt) {
@@ -31,7 +33,15 @@ export function FileViewerContent({ path, content }: FileViewerContentProps) {
highlightActiveLine: false,
highlightSelectionMatches: true,
}}
className="h-full overflow-auto text-xs"
className={cn(
// Wrapper must have a bounded height so CodeMirror's internal .cm-scroller
// scrolls instead of expanding to content height. touch-pan-y on
// .cm-scroller is what makes vertical touch scroll work on mobile —
// without it, .cm-content captures the gesture for selection.
"h-full overflow-hidden text-xs",
"[&_.cm-scroller]:touch-pan-y [&_.cm-scroller]:overscroll-contain",
className,
)}
/>
);
}
@@ -0,0 +1,98 @@
"use client";
import { useMemo, useState } from "react";
import { IconEye } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { PanelBody, PanelHeaderBarSplit, PanelRoot } from "../panel-primitives";
import { FileViewerContent } from "../file-viewer-content";
import { MarkdownPreviewContent } from "../markdown-preview-content";
import { FileImageViewer } from "../file-image-viewer";
import { FileBinaryViewer } from "../file-binary-viewer";
import { getFileCategory } from "@/lib/utils/file-types";
import { useAppStore } from "@/components/state-provider";
import type { OpenFileTab } from "@/lib/types/backend";
type MobileFileViewerPanelProps = {
file: OpenFileTab;
sessionId: string | null;
onClose: () => void;
};
function isMarkdownFile(path: string): boolean {
const ext = path.split(".").pop()?.toLowerCase();
return ext === "md" || ext === "mdx";
}
function resolveViewerKind(file: OpenFileTab): "image" | "binary" | "text" {
if (!file.isBinary) return "text";
return getFileCategory(file.path) === "image" ? "image" : "binary";
}
export function MobileFileViewerPanel({ file, sessionId, onClose }: MobileFileViewerPanelProps) {
const activeSession = useAppStore((state) =>
sessionId ? (state.taskSessions.items[sessionId] ?? null) : null,
);
const worktreePath = activeSession?.worktree_path ?? undefined;
const viewerKind = useMemo(() => resolveViewerKind(file), [file]);
const markdownFile = isMarkdownFile(file.path);
const [markdownPreview, setMarkdownPreview] = useState(false);
const [lastPath, setLastPath] = useState(file.path);
const content = file.content;
// Reset preview mode when the file changes so reopening a markdown file
// always starts in editor view, not the previous preview state.
// Adjust state during render per React docs recommendation.
if (lastPath !== file.path) {
setLastPath(file.path);
setMarkdownPreview(false);
}
return (
<PanelRoot data-testid="mobile-file-viewer-panel">
<PanelHeaderBarSplit
left={<span className="truncate font-mono text-xs">{file.path}</span>}
right={
<div className="flex items-center gap-1">
{markdownFile && !markdownPreview && (
<Button
variant="ghost"
size="sm"
className="cursor-pointer px-2"
onClick={() => setMarkdownPreview(true)}
data-testid="markdown-preview-toggle"
aria-label="Open markdown preview"
>
<IconEye className="h-4 w-4" />
</Button>
)}
<Button variant="ghost" size="sm" className="cursor-pointer px-2" onClick={onClose}>
Close
</Button>
</div>
}
/>
<PanelBody padding={false} scroll={false} className="overflow-hidden">
<div className="flex h-full min-h-0 flex-col" data-testid="mobile-file-viewer-content">
{viewerKind === "image" && (
<FileImageViewer path={file.path} content={file.content} worktreePath={worktreePath} />
)}
{viewerKind === "binary" && (
<FileBinaryViewer path={file.path} worktreePath={worktreePath} />
)}
{viewerKind === "text" &&
(markdownFile && markdownPreview ? (
<MarkdownPreviewContent
path={file.path}
content={content}
worktreePath={worktreePath}
onTogglePreview={() => setMarkdownPreview((current) => !current)}
/>
) : (
<FileViewerContent path={file.path} content={content} />
))}
</div>
</PanelBody>
</PanelRoot>
);
}
@@ -0,0 +1,141 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import type { OpenFileTab } from "@/lib/types/backend";
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: vi.fn() }),
}));
const fetchAndOpenFileMock = vi.fn();
vi.mock("../file-browser-hooks", () => ({
fetchAndOpenFile: (...args: unknown[]) => fetchAndOpenFileMock(...args),
}));
import { useMobilePanelHandlers } from "./session-mobile-layout";
const MOCK_FILE: OpenFileTab = {
path: "src/foo.ts",
name: "foo.ts",
content: "export const x = 1;",
originalContent: "export const x = 1;",
originalHash: "abc123",
isDirty: false,
};
const OTHER_FILE: OpenFileTab = {
...MOCK_FILE,
path: "src/bar.ts",
name: "bar.ts",
};
const CHAT_LINK_PATH = "src/chat-link.ts";
function renderHandlers(initialSid: string | null = "s1") {
const handlePanelChange = vi.fn();
const view = renderHook(
({ sid }) => useMobilePanelHandlers({ effectiveSessionId: sid, handlePanelChange }),
{ initialProps: { sid: initialSid } },
);
return { handlePanelChange, ...view };
}
describe("useMobilePanelHandlers", () => {
beforeEach(() => {
fetchAndOpenFileMock.mockReset();
});
it("handleOpenFile sets selectedFile and switches to files panel", () => {
const { result, handlePanelChange } = renderHandlers();
expect(result.current.selectedFile).toBeNull();
act(() => result.current.handleOpenFile(MOCK_FILE));
expect(result.current.selectedFile).toEqual(MOCK_FILE);
expect(handlePanelChange).toHaveBeenCalledWith("files");
});
it("handleOpenFileFromChat fetches and opens the viewer panel", () => {
const { result, handlePanelChange } = renderHandlers();
act(() => result.current.handleOpenFileFromChat(CHAT_LINK_PATH));
expect(fetchAndOpenFileMock).toHaveBeenCalledWith(
"s1",
CHAT_LINK_PATH,
expect.any(Function),
expect.any(Function),
);
const openFile = fetchAndOpenFileMock.mock.calls[0]?.[2] as (file: OpenFileTab) => void;
act(() => openFile(MOCK_FILE));
expect(result.current.selectedFile).toEqual(MOCK_FILE);
expect(handlePanelChange).toHaveBeenCalledWith("files");
});
it("handleOpenFileFromChat no-ops when no active session", () => {
const { result } = renderHandlers(null);
act(() => result.current.handleOpenFileFromChat(CHAT_LINK_PATH));
expect(fetchAndOpenFileMock).not.toHaveBeenCalled();
expect(result.current.selectedFile).toBeNull();
});
it("handlePanelChangeAndClearSheet clears the viewer when switching panels", () => {
const { result, handlePanelChange } = renderHandlers();
act(() => result.current.handleOpenFile(MOCK_FILE));
expect(result.current.selectedFile).toEqual(MOCK_FILE);
act(() => result.current.handlePanelChangeAndClearSheet("plan"));
expect(result.current.selectedFile).toBeNull();
expect(handlePanelChange).toHaveBeenCalledWith("plan");
});
it("clears selectedFile when effectiveSessionId changes", () => {
const { result, rerender } = renderHandlers();
act(() => result.current.handleOpenFile(MOCK_FILE));
expect(result.current.selectedFile).toEqual(MOCK_FILE);
rerender({ sid: "s2" });
expect(result.current.selectedFile).toBeNull();
});
it("keeps selectedFile when rerendered with same session", () => {
const { result, rerender } = renderHandlers();
act(() => result.current.handleOpenFile(MOCK_FILE));
rerender({ sid: "s1" });
expect(result.current.selectedFile).toEqual(MOCK_FILE);
});
it("rejects stale handleOpenFileFromChat callback after session change", () => {
const { result, rerender } = renderHandlers();
act(() => result.current.handleOpenFileFromChat(CHAT_LINK_PATH));
expect(fetchAndOpenFileMock).toHaveBeenCalledWith(
"s1",
CHAT_LINK_PATH,
expect.any(Function),
expect.any(Function),
);
// Simulate session switch before the async callback fires
rerender({ sid: "s2" });
expect(result.current.selectedFile).toBeNull();
// Invoke the stale callback that was registered for session s1
const staleCallback = fetchAndOpenFileMock.mock.calls[0]?.[2] as (file: OpenFileTab) => void;
act(() => staleCallback(MOCK_FILE));
// Should still be null because the callback belongs to the old session
expect(result.current.selectedFile).toBeNull();
});
it("latest handleOpenFile call wins", () => {
const { result } = renderHandlers();
act(() => {
result.current.handleOpenFile(MOCK_FILE);
result.current.handleOpenFile(OTHER_FILE);
});
expect(result.current.selectedFile).toEqual(OTHER_FILE);
});
});
@@ -1,9 +1,10 @@
"use client";
import { memo, useCallback } from "react";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { SessionMobileTopBar } from "./session-mobile-top-bar";
import { SessionMobileBottomNav } from "./session-mobile-bottom-nav";
import { SessionTaskSwitcherSheet } from "./session-task-switcher-sheet";
import { MobileFileViewerPanel } from "./mobile-file-viewer-panel";
import { TaskChatPanel } from "../task-chat-panel";
import { TaskPlanPanel } from "../task-plan-panel";
import { MobileChangesPanel } from "./mobile-changes-panel";
@@ -17,7 +18,10 @@ import { MobileSessionsPicker } from "./mobile-sessions-section";
import { SessionPanelContent } from "@kandev/ui/pannel-session";
import { useSessionLayoutState } from "@/hooks/use-session-layout-state";
import { useVisualViewportOffset } from "@/hooks/use-visual-viewport-offset";
import { useToast } from "@/components/toast-provider";
import { fetchAndOpenFile } from "../file-browser-hooks";
import type { MobileSessionPanel } from "@/lib/state/slices/ui/types";
import type { OpenFileTab } from "@/lib/types/backend";
const TOP_NAV_HEIGHT = "3.5rem";
const BOTTOM_NAV_HEIGHT = "3.25rem";
@@ -81,10 +85,12 @@ type MobilePanelAreaProps = {
activeTaskId: string | null;
isPassthroughMode: boolean;
effectiveSessionId: string | null;
selectedFile: OpenFileTab | null;
selectedDiff: { path: string; content?: string } | null;
handleOpenFileFromChat: (path: string) => void;
handleClearSelectedDiff: () => void;
handleOpenFile: (file: { path: string }) => void;
handleOpenFile: (file: OpenFileTab) => void;
handlePanelChangeAndClearSheet: (panel: MobileSessionPanel) => void;
topNavHeight: string;
bottomNavHeight: string;
};
@@ -94,10 +100,12 @@ function MobilePanelArea({
activeTaskId,
isPassthroughMode,
effectiveSessionId,
selectedFile,
selectedDiff,
handleOpenFileFromChat,
handleClearSelectedDiff,
handleOpenFile,
handlePanelChangeAndClearSheet,
topNavHeight,
bottomNavHeight,
}: MobilePanelAreaProps) {
@@ -144,7 +152,16 @@ function MobilePanelArea({
)}
{currentMobilePanel === "files" && (
<div className="flex-1 min-h-0 flex flex-col">
<TaskFilesPanel onOpenFile={handleOpenFile} />
{selectedFile ? (
<MobileFileViewerPanel
key={selectedFile.path}
file={selectedFile}
sessionId={effectiveSessionId}
onClose={() => handlePanelChangeAndClearSheet("files")}
/>
) : (
<TaskFilesPanel onOpenFile={handleOpenFile} />
)}
</div>
)}
{currentMobilePanel === "terminal" && (
@@ -232,30 +249,71 @@ function MobileReviewDialogMount({
);
}
function useMobilePanelHandlers({
handleSelectDiff,
export function useMobilePanelHandlers({
effectiveSessionId,
handlePanelChange,
}: {
handleSelectDiff: (path: string, content?: string) => void;
effectiveSessionId: string | null;
handlePanelChange: (panel: MobileSessionPanel) => void;
}) {
const { toast } = useToast();
const [selectedFile, setSelectedFile] = useState<OpenFileTab | null>(null);
const [trackedSessionId, setTrackedSessionId] = useState<string | null>(effectiveSessionId);
const latestRequestIdRef = useRef(0);
// Reset viewer when switching sessions — adjust state during render per
// https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
if (trackedSessionId !== effectiveSessionId) {
setTrackedSessionId(effectiveSessionId);
setSelectedFile(null);
}
useEffect(() => {
latestRequestIdRef.current += 1;
}, [effectiveSessionId]);
const handleOpenFileFromChat = useCallback(
(path: string) => {
handleSelectDiff(path);
handlePanelChange("changes");
if (!effectiveSessionId) return;
const requestId = (latestRequestIdRef.current += 1);
void fetchAndOpenFile(
effectiveSessionId,
path,
(file) => {
if (requestId !== latestRequestIdRef.current) return;
setSelectedFile(file);
handlePanelChange("files");
},
toast,
);
},
[handleSelectDiff, handlePanelChange],
[effectiveSessionId, handlePanelChange, toast],
);
const handleOpenFile = useCallback(
(file: { path: string }) => {
handleSelectDiff(file.path);
handlePanelChange("changes");
(file: OpenFileTab) => {
latestRequestIdRef.current += 1;
setSelectedFile(file);
handlePanelChange("files");
},
[handleSelectDiff, handlePanelChange],
[handlePanelChange],
);
return { handleOpenFileFromChat, handleOpenFile };
const handlePanelChangeAndClearSheet = useCallback(
(panel: MobileSessionPanel) => {
latestRequestIdRef.current += 1;
setSelectedFile(null);
handlePanelChange(panel);
},
[handlePanelChange],
);
return {
selectedFile,
handleOpenFileFromChat,
handleOpenFile,
handlePanelChangeAndClearSheet,
};
}
export const SessionMobileLayout = memo(function SessionMobileLayout({
@@ -273,13 +331,11 @@ export const SessionMobileLayout = memo(function SessionMobileLayout({
remoteCheckedAt,
remoteStatusError,
}: SessionMobileLayoutProps) {
// Use shared layout state hook
const {
activeTaskId,
effectiveSessionId,
isPassthroughMode,
selectedDiff,
handleSelectDiff,
handleClearSelectedDiff,
totalChangesCount,
hasUnseenPlanUpdate,
@@ -292,10 +348,8 @@ export const SessionMobileLayout = memo(function SessionMobileLayout({
setMobileSessionTaskSwitcherOpen,
} = useSessionLayoutState({ sessionId });
const { handleOpenFileFromChat, handleOpenFile } = useMobilePanelHandlers({
handleSelectDiff,
handlePanelChange,
});
const { selectedFile, handleOpenFileFromChat, handleOpenFile, handlePanelChangeAndClearSheet } =
useMobilePanelHandlers({ effectiveSessionId, handlePanelChange });
const review = useReviewDialog(effectiveSessionId);
@@ -320,21 +374,21 @@ export const SessionMobileLayout = memo(function SessionMobileLayout({
remoteStatusError={remoteStatusError}
/>
{/* Content Area - fixed height panels that manage their own scrolling */}
<MobilePanelArea
currentMobilePanel={currentMobilePanel}
activeTaskId={activeTaskId}
isPassthroughMode={isPassthroughMode}
effectiveSessionId={effectiveSessionId}
selectedFile={selectedFile}
selectedDiff={selectedDiff}
handleOpenFileFromChat={handleOpenFileFromChat}
handleClearSelectedDiff={handleClearSelectedDiff}
handleOpenFile={handleOpenFile}
handlePanelChangeAndClearSheet={handlePanelChangeAndClearSheet}
topNavHeight={TOP_NAV_HEIGHT}
bottomNavHeight={BOTTOM_NAV_HEIGHT}
/>
{/* Terminal key-bar — docks above OS keyboard or bottom nav on the terminal panel */}
<MobileTerminalKeybar
sessionId={effectiveSessionId ?? null}
visible={currentMobilePanel === "terminal"}
@@ -344,7 +398,7 @@ export const SessionMobileLayout = memo(function SessionMobileLayout({
{/* Fixed Bottom Navigation */}
<SessionMobileBottomNav
activePanel={currentMobilePanel}
onPanelChange={handlePanelChange}
onPanelChange={handlePanelChangeAndClearSheet}
planBadge={hasUnseenPlanUpdate}
changesBadge={totalChangesCount}
/>
+2 -2
View File
@@ -29,13 +29,13 @@ export class GitHelper {
throw new Error(`git exec failed after 3 attempts: ${cmd}`);
}
createFile(name: string, content: string) {
createFile(name: string, content: string | Buffer) {
const filePath = path.join(this.repoDir, name);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
}
modifyFile(name: string, content: string) {
modifyFile(name: string, content: string | Buffer) {
this.createFile(name, content);
}
@@ -0,0 +1,315 @@
// Routing: /t/{taskId} (task-keyed). File starts with "mobile-" so it runs on
// the mobile-chrome Playwright project (Pixel 5 emulation).
//
// Regression guard: tapping a file in the Files → All files tab must replace
// the Files panel with the file viewer, NOT navigate to the Changes panel.
import { type Page } from "@playwright/test";
import path from "node:path";
import { test, expect } from "../../fixtures/test-base";
import type { SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import type { BackendContext } from "../../fixtures/backend";
import { GitHelper, makeGitEnv, createStandardProfile } from "../../helpers/git-helper";
import { SessionPage } from "../../pages/session-page";
function createLongFileContent(lines = 500): string {
return Array.from(
{ length: lines },
(_, index) => `export const line_${index} = "line ${index}";`,
).join("\n");
}
async function setupMobileFileViewerTest({
testPage,
apiClient,
seedData,
backend,
taskTitle,
options,
}: {
testPage: Page;
apiClient: ApiClient;
seedData: SeedData;
backend: BackendContext;
taskTitle: string;
options?: {
extension?: string;
content?: string | Buffer;
directory?: string;
};
}): Promise<{ session: SessionPage; filePath: string }> {
const fileExtension = options?.extension ?? "ts";
const fileContent = options?.content ?? 'export const greeting = "hello";';
const fileName = `viewer-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${fileExtension}`;
const filePath = options?.directory ? `${options.directory}/${fileName}` : fileName;
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
makeGitEnv(backend.tmpDir),
);
git.createFile(filePath, fileContent);
git.stageAll();
git.commit(`add ${filePath}`);
const profile = await createStandardProfile(apiClient, `mobile-fv-${Date.now()}`);
const task = await apiClient.createTaskWithAgent(seedData.workspaceId, taskTitle, profile.id, {
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.idleInput()).toBeVisible({ timeout: 45_000 });
return { session, filePath };
}
test.describe("Mobile file viewer panel", () => {
test.describe.configure({ retries: 1 });
test("tapping file in All-files tab opens viewer panel instead of Changes panel", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(90_000);
const { filePath } = await setupMobileFileViewerTest({
testPage,
apiClient,
seedData,
backend,
taskTitle: "Mobile FV Open",
});
// Navigate to the Files panel via the bottom nav
await testPage.getByRole("button", { name: "Files" }).tap();
// Wait for the file to appear in the browser tree
const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`);
await expect(fileNode).toBeVisible({ timeout: 15_000 });
// Tap the file — must open the inline viewer panel, NOT switch to the Changes panel
await fileNode.tap();
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
await expect(viewer).toBeVisible({ timeout: 5_000 });
await expect(viewer.getByText(filePath)).toBeVisible();
});
test("closing the viewer panel returns to the files browser", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(90_000);
const { filePath } = await setupMobileFileViewerTest({
testPage,
apiClient,
seedData,
backend,
taskTitle: "Mobile FV Close",
});
await testPage.getByRole("button", { name: "Files" }).tap();
const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`);
await expect(fileNode).toBeVisible({ timeout: 15_000 });
await fileNode.tap();
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
await expect(viewer).toBeVisible({ timeout: 5_000 });
await viewer.getByRole("button", { name: "Close" }).tap();
await expect(viewer).not.toBeVisible({ timeout: 5_000 });
await expect(fileNode).toBeVisible();
});
test("binary files show preview-unavailable message in viewer panel", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(90_000);
const { filePath } = await setupMobileFileViewerTest({
testPage,
apiClient,
seedData,
backend,
taskTitle: "Mobile FV Binary",
options: { extension: "bin", content: Buffer.from([0x00, 0x01, 0x02, 0x03, 0xff, 0x10]) },
});
await testPage.getByRole("button", { name: "Files" }).tap();
const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`);
await expect(fileNode).toBeVisible({ timeout: 15_000 });
await fileNode.tap();
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
await expect(viewer).toBeVisible({ timeout: 5_000 });
await expect(viewer.getByText("Cannot preview this file")).toBeVisible();
await expect(viewer.getByText("Binary file")).toBeVisible();
});
test("viewer panel keeps a visible close action and supports scrolling large files", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(90_000);
const { filePath } = await setupMobileFileViewerTest({
testPage,
apiClient,
seedData,
backend,
taskTitle: "Mobile FV Scroll",
options: { extension: "ts", content: createLongFileContent(1_000) },
});
await testPage.getByRole("button", { name: "Files" }).tap();
const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`);
await expect(fileNode).toBeVisible({ timeout: 15_000 });
await fileNode.tap();
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
await expect(viewer).toBeVisible({ timeout: 5_000 });
const closeButton = viewer.getByRole("button", { name: "Close" });
await expect(closeButton).toBeVisible();
await expect(closeButton).toBeInViewport();
// CodeMirror's `.cm-scroller` owns the scroll for text files (matches the
// desktop editor tab). Verify it's the element that actually scrolls.
const cmScrollerLocator = viewer.locator(".cm-scroller");
await expect(cmScrollerLocator).toHaveCount(1);
const cmScroller = cmScrollerLocator.first();
await expect(cmScroller).toBeVisible();
const metrics = await cmScroller.evaluate((element) => ({
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
touchAction: getComputedStyle(element).touchAction,
overflowY: getComputedStyle(element).overflowY,
}));
expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight);
// touch-action must allow vertical pan or mobile touch scroll won't work.
expect(metrics.touchAction).toMatch(/pan-y|auto/);
expect(metrics.overflowY).toMatch(/auto|scroll/);
const scrollTopAfterMove = await cmScroller.evaluate((element) => {
element.scrollTop = 0;
element.scrollBy(0, 1_400);
return element.scrollTop;
});
expect(scrollTopAfterMove).toBeGreaterThan(0);
// Real touch swipe: dispatch CDP touch events to exercise the gesture path
// that the programmatic scrollBy above bypasses. This is the path the user
// actually triggers when finger-scrolling a long file.
await cmScroller.evaluate((element) => {
element.scrollTop = 0;
});
const box = await cmScroller.boundingBox();
if (!box) throw new Error("cm-scroller has no bounding box");
const cdp = await testPage.context().newCDPSession(testPage);
const centerX = box.x + box.width / 2;
const startY = box.y + box.height - 20;
const endY = box.y + 20;
await cdp.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ x: centerX, y: startY }],
});
for (let i = 1; i <= 8; i++) {
const y = startY + ((endY - startY) * i) / 8;
await cdp.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: centerX, y }],
});
}
await cdp.send("Input.dispatchTouchEvent", {
type: "touchEnd",
touchPoints: [],
});
await expect
.poll(async () => cmScroller.evaluate((element) => element.scrollTop), {
timeout: 5_000,
})
.toBeGreaterThan(0);
});
test("markdown files can toggle preview in the viewer panel", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(90_000);
const { filePath } = await setupMobileFileViewerTest({
testPage,
apiClient,
seedData,
backend,
taskTitle: "Mobile FV Markdown",
options: { extension: "md", content: "# Heading\n\nBody text" },
});
await testPage.getByRole("button", { name: "Files" }).tap();
const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`);
await expect(fileNode).toBeVisible({ timeout: 15_000 });
await fileNode.tap();
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
await expect(viewer).toBeVisible({ timeout: 5_000 });
await viewer.getByTestId("markdown-preview-toggle").tap();
await expect(viewer.getByTestId("markdown-preview")).toBeVisible();
});
test("viewer header shows full path for files inside subdirectories", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(90_000);
const directory = `nested-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const { filePath } = await setupMobileFileViewerTest({
testPage,
apiClient,
seedData,
backend,
taskTitle: "Mobile FV Subdir",
options: { directory },
});
await testPage.getByRole("button", { name: "Files" }).tap();
const dirNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${directory}"]`);
await expect(dirNode).toBeVisible({ timeout: 15_000 });
await dirNode.tap();
const fileNode = testPage.locator(`[data-testid="file-tree-node"][data-path="${filePath}"]`);
await expect(fileNode).toBeVisible({ timeout: 15_000 });
await fileNode.tap();
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
await expect(viewer).toBeVisible({ timeout: 5_000 });
// Header must show full relative path, not just the basename.
await expect(viewer.getByText(filePath)).toBeVisible();
});
});