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(); + }); +});