fix: open agent file links that carry line-range selectors (#1412)

This commit is contained in:
Clément Fiere
2026-06-17 21:32:40 +02:00
committed by GitHub
parent 67037fd936
commit a71ee2e2c1
14 changed files with 798 additions and 52 deletions
@@ -312,4 +312,131 @@ test.describe("Mobile file viewer panel", () => {
// Header must show full relative path, not just the basename.
await expect(viewer.getByText(filePath)).toBeVisible();
});
// A chat read/edit file link can target a line deep in a file. Tapping it sets
// a pending cursor position (use-file-editors), then on mobile the file opens
// through MobileFileViewerPanel → FileViewerContent (CodeMirror), which must
// consume that pending entry and scroll the target line into view. Regression
// guard for the mobile half of "open-and-scroll-to-line": before the fix
// CodeMirror ignored the pending map and the file opened pinned at the top.
test("tapping a chat read link scrolls the CodeMirror viewer to the target line", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(90_000);
const fileName = `chat-read-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.ts`;
const filePath = fileName;
const git = new GitHelper(
path.join(backend.tmpDir, "repos", "e2e-repo"),
makeGitEnv(backend.tmpDir),
);
git.createFile(filePath, createLongFileContent(500));
git.stageAll();
git.commit(`add ${filePath}`);
const profile = await createStandardProfile(apiClient, `mobile-fv-scroll-${Date.now()}`);
// `e2e:message(...)` emits a single non-activity text message — no thinking
// and no tool calls — so the seeded read card below is the only activity
// message and renders as a standalone, directly tappable card (a turn group
// only forms with ≥2 activity messages in the same turn).
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mobile FV Scroll To Line",
profile.id,
{
description: 'e2e:message("ready")',
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
// Seed a real read card pointing deep into the file. The chat read-message
// component sets the pending cursor position from `offset` before opening,
// keyed by this exact `file_path` — the same string the mobile viewer keys
// FileViewerContent on, so the consume matches.
const targetLine = 300;
await apiClient.seedSessionMessage(task.session_id, {
type: "tool_read",
content: "Read file",
metadata: {
status: "complete",
tool_call_id: "tc-chat-read-scroll",
normalized: {
read_file: {
file_path: filePath,
offset: targetLine,
limit: 20,
output: { content: 'export const line_299 = "line 299";', line_count: 20 },
},
},
},
});
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 45_000 });
// FilePathButton renders the openable link with the seeded path as `title`.
const chat = session.activeChat();
const fileLink = chat.locator(`button[title="${filePath}"]`);
await expect(fileLink).toBeVisible({ timeout: 15_000 });
await fileLink.tap();
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
await expect(viewer).toBeVisible({ timeout: 10_000 });
const cmScroller = viewer.locator(".cm-scroller").first();
await expect(cmScroller).toBeVisible();
// FileViewerContent consumed the pending position on mount and scrolled the
// target line to centre — the scroller is no longer pinned at the top.
await expect
.poll(async () => cmScroller.evaluate((element) => element.scrollTop), { timeout: 10_000 })
.toBeGreaterThan(0);
// The target line's gutter number is rendered and on-screen, proving the
// scroll landed on the requested line rather than an arbitrary offset.
await expect(
viewer.locator(".cm-lineNumbers .cm-gutterElement", { hasText: "300" }),
).toBeInViewport();
});
// Opening a file with NO pending cursor position (normal file browsing) must
// not scroll — the CodeMirror viewer stays pinned at the top.
test("opening a file without a pending cursor position stays at the top", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(90_000);
const { filePath } = await setupMobileFileViewerTest({
testPage,
apiClient,
seedData,
backend,
taskTitle: "Mobile FV No Scroll",
options: { extension: "ts", content: createLongFileContent(500) },
});
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 cmScroller = viewer.locator(".cm-scroller").first();
await expect(cmScroller).toBeVisible();
// No pending entry → no scroll-to-line dispatch → scroller stays at the top.
expect(await cmScroller.evaluate((element) => element.scrollTop)).toBe(0);
});
});