diff --git a/apps/web/components/task/file-viewer-content.tsx b/apps/web/components/task/file-viewer-content.tsx
index aa5d66b98b..847f8dd279 100644
--- a/apps/web/components/task/file-viewer-content.tsx
+++ b/apps/web/components/task/file-viewer-content.tsx
@@ -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,
+ )}
/>
);
}
diff --git a/apps/web/components/task/mobile/mobile-file-viewer-panel.tsx b/apps/web/components/task/mobile/mobile-file-viewer-panel.tsx
new file mode 100644
index 0000000000..57d3abec83
--- /dev/null
+++ b/apps/web/components/task/mobile/mobile-file-viewer-panel.tsx
@@ -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 (
+
+ {file.path}}
+ right={
+
+ {markdownFile && !markdownPreview && (
+
+ )}
+
+
+ }
+ />
+
+
+ {viewerKind === "image" && (
+
+ )}
+ {viewerKind === "binary" && (
+
+ )}
+ {viewerKind === "text" &&
+ (markdownFile && markdownPreview ? (
+ setMarkdownPreview((current) => !current)}
+ />
+ ) : (
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/web/components/task/mobile/session-mobile-layout.test.tsx b/apps/web/components/task/mobile/session-mobile-layout.test.tsx
new file mode 100644
index 0000000000..0ef5191552
--- /dev/null
+++ b/apps/web/components/task/mobile/session-mobile-layout.test.tsx
@@ -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);
+ });
+});
diff --git a/apps/web/components/task/mobile/session-mobile-layout.tsx b/apps/web/components/task/mobile/session-mobile-layout.tsx
index 1e0ade1a9d..1363dcd1ec 100644
--- a/apps/web/components/task/mobile/session-mobile-layout.tsx
+++ b/apps/web/components/task/mobile/session-mobile-layout.tsx
@@ -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" && (
-
+ {selectedFile ? (
+ handlePanelChangeAndClearSheet("files")}
+ />
+ ) : (
+
+ )}
)}
{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(null);
+ const [trackedSessionId, setTrackedSessionId] = useState(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 */}
- {/* Terminal key-bar — docks above OS keyboard or bottom nav on the terminal panel */}
diff --git a/apps/web/e2e/helpers/git-helper.ts b/apps/web/e2e/helpers/git-helper.ts
index 74bc45d6d8..7b7b2ac1de 100644
--- a/apps/web/e2e/helpers/git-helper.ts
+++ b/apps/web/e2e/helpers/git-helper.ts
@@ -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);
}
diff --git a/apps/web/e2e/tests/task/mobile-file-viewer.spec.ts b/apps/web/e2e/tests/task/mobile-file-viewer.spec.ts
new file mode 100644
index 0000000000..a5bed42107
--- /dev/null
+++ b/apps/web/e2e/tests/task/mobile-file-viewer.spec.ts
@@ -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();
+ });
+});