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
@@ -198,6 +198,29 @@ describe("markdownComponents", () => {
});
});
describe("markdownComponents omp read selectors", () => {
afterEach(() => {
cleanup();
openFile.mockClear();
appState.value.tasks.activeSessionId = "session-1";
appState.value.taskSessions.items = {
"session-1": { worktree_path: "/root/.kandev/tasks/example/kandev" },
};
});
it("opens file links with omp range and multi-range selectors in the editor", () => {
render(<Markdown>{"[readme](docs/README.md:16-20)"}</Markdown>);
fireEvent.click(screen.getByRole("link", { name: "readme" }));
expect(openFile).toHaveBeenCalledWith("docs/README.md");
});
it("opens file links with omp multi-range and mode selectors in the editor", () => {
render(<Markdown>{"[readme](docs/README.md:5-16,960-973:raw)"}</Markdown>);
fireEvent.click(screen.getByRole("link", { name: "readme" }));
expect(openFile).toHaveBeenCalledWith("docs/README.md");
});
});
describe("normalizeMarkdown", () => {
it("splits a glued 4-backtick close", () => {
const input = "````go\nfunc f() {\n ...\n}````\nprose continues here";
@@ -80,8 +80,12 @@ function stripHashAndQuery(href: string): string {
return href.split(/[?#]/, 1)[0] ?? "";
}
// Strip a trailing source-location / omp read selector so a file link resolves
// to the bare path: ":42", ":42:5", and omp ranges like ":16-20", ":50+150",
// ":5-16,960-973", ":2-4:raw", ":raw", ":conflicts".
function stripSourceLocationSuffix(path: string): string {
return path.replace(/:\d+(?::\d+)?$/, "");
const part = String.raw`\d+(?:[-+]\d+)?(?:,\d+(?:[-+]\d+)?)*|raw|conflicts`;
return path.replace(new RegExp(`:(?:${part})(?::(?:${part}))*$`), "");
}
function decodeHrefPath(href: string): string | null {
@@ -1,6 +1,6 @@
"use client";
import { useState, memo } from "react";
import { useState, memo, useCallback } from "react";
import {
IconCheck,
IconX,
@@ -16,6 +16,7 @@ import { DiffViewBlock } from "./diff-view-block";
import { ExpandableRow } from "./expandable-row";
import { transformFileMutation, type FileMutation } from "@/lib/diff";
import { useExpandState } from "./use-expand-state";
import { setPendingCursorPosition, scrollEditorIfMounted } from "@/hooks/use-file-editors";
type ModifyFilePayload = {
file_path?: string;
@@ -34,6 +35,7 @@ type ToolEditMessageProps = {
onOpenFile?: (path: string) => void;
};
// EditStatusIcon renders the status glyph (complete/error/running) for an edit card.
function EditStatusIcon({ status }: { status: string | undefined }) {
if (status === "complete") return <IconCheck className="h-3.5 w-3.5 text-green-500" />;
if (status === "error") return <IconX className="h-3.5 w-3.5 text-red-500" />;
@@ -41,6 +43,7 @@ function EditStatusIcon({ status }: { status: string | undefined }) {
return null;
}
// getEditSummary returns the short header label for an edit/write card.
function getEditSummary(
content: string,
worktreePath: string | undefined,
@@ -62,6 +65,7 @@ type FileActionButtonProps = {
onCopyPath: (e: React.MouseEvent) => void;
};
// FileActionButton renders the file path with an optional open-in-editor action.
function FileActionButton({
filePath,
worktreePath,
@@ -109,6 +113,7 @@ type EditExpandedContentProps = {
writeContent: string | undefined;
};
// EditExpandedContent renders the expanded body of an edit card: a diff or write content.
function EditExpandedContent({ diffData, writeContent }: EditExpandedContentProps) {
if (diffData?.diff) return <DiffViewBlock data={diffData} className="mt-0 border-0 px-0" />;
if (writeContent) {
@@ -123,6 +128,8 @@ function EditExpandedContent({ diffData, writeContent }: EditExpandedContentProp
return null;
}
// parseEditMetadata pulls the edit status, file path, first-changed line,
// diff/write content, and expandability out of a tool_edit message's metadata.
function parseEditMetadata(comment: Message) {
const metadata = comment.metadata as ToolEditMetadata | undefined;
const status = metadata?.status;
@@ -138,6 +145,7 @@ function parseEditMetadata(comment: Message) {
return {
status,
filePath,
startLine: mutation?.start_line,
writeContent,
isWriteOperation,
diffData,
@@ -147,6 +155,7 @@ function parseEditMetadata(comment: Message) {
};
}
// ToolEditMessage renders an edit/write tool call: the file link and expandable diff.
export const ToolEditMessage = memo(function ToolEditMessage({
comment,
worktreePath,
@@ -155,6 +164,7 @@ export const ToolEditMessage = memo(function ToolEditMessage({
const {
status,
filePath,
startLine,
writeContent,
isWriteOperation,
diffData,
@@ -177,6 +187,22 @@ export const ToolEditMessage = memo(function ToolEditMessage({
}
};
// Navigate the editor to the first changed line. For a newly opened file the
// pending position is consumed on editor mount; for an already-open file no
// mount happens, so scroll the live editor directly.
const handleOpenFile = useCallback(
(path: string) => {
if (startLine && startLine > 0) {
setPendingCursorPosition(path, startLine, 1);
onOpenFile?.(path);
scrollEditorIfMounted(path, worktreePath ?? null, startLine, 1);
return;
}
onOpenFile?.(path);
},
[onOpenFile, startLine, worktreePath],
);
return (
<ExpandableRow
icon={<Icon className="h-4 w-4 text-muted-foreground" />}
@@ -190,7 +216,7 @@ export const ToolEditMessage = memo(function ToolEditMessage({
<FileActionButton
filePath={filePath}
worktreePath={worktreePath}
onOpenFile={onOpenFile}
onOpenFile={onOpenFile ? handleOpenFile : undefined}
copied={copied}
onCopyPath={handleCopyPath}
/>
@@ -1,12 +1,13 @@
"use client";
import { memo } from "react";
import { memo, useCallback } from "react";
import { IconCheck, IconX, IconFileCode2 } from "@tabler/icons-react";
import { GridSpinner } from "@/components/grid-spinner";
import { FilePathButton } from "./file-path-button";
import type { Message } from "@/lib/types/http";
import { ExpandableRow } from "./expandable-row";
import { useExpandState } from "./use-expand-state";
import { setPendingCursorPosition, scrollEditorIfMounted } from "@/hooks/use-file-editors";
type ReadFileOutput = {
content?: string;
@@ -36,6 +37,7 @@ type ToolReadMessageProps = {
onOpenFile?: (path: string) => void;
};
// ReadStatusIcon renders the status glyph (complete/error/running) for a read card.
function ReadStatusIcon({ status }: { status: string | undefined }) {
if (status === "complete") return <IconCheck className="h-3.5 w-3.5 text-green-500" />;
if (status === "error") return <IconX className="h-3.5 w-3.5 text-red-500" />;
@@ -43,30 +45,62 @@ function ReadStatusIcon({ status }: { status: string | undefined }) {
return null;
}
// getReadSummary returns the short "Read N lines" header label for the card.
function getReadSummary(lineCount: number | undefined): string {
if (lineCount) return `Read ${lineCount} line${lineCount !== 1 ? "s" : ""}`;
return "Read";
}
// formatLineRange renders the range the agent read (carried separately from
// the file path so the link stays openable). offset is the 1-based start line;
// limit is the line count (0 when open-ended / a single anchor).
function formatLineRange(offset: number | undefined, limit: number | undefined): string {
if (!offset || offset <= 0) return "";
if (limit && limit > 0) return `:${offset}-${offset + limit - 1}`;
return `:${offset}`;
}
// parseReadMetadata pulls the read status, file path, line range, and output
// out of a tool_read message's normalized metadata for rendering.
function parseReadMetadata(comment: Message) {
const metadata = comment.metadata as ToolReadMetadata | undefined;
const status = metadata?.status;
const readFile = metadata?.normalized?.read_file;
const readOutput = readFile?.output;
const filePath = readFile?.file_path;
const startLine = readFile?.offset;
const lineRange = formatLineRange(readFile?.offset, readFile?.limit);
const hasOutput = !!readOutput?.content;
const isSuccess = status === "complete";
return { status, readOutput, filePath, hasOutput, isSuccess };
return { status, readOutput, filePath, startLine, lineRange, hasOutput, isSuccess };
}
// ToolReadMessage renders a read tool call: the file link, line-range badge, and
// the (expandable) read output.
export const ToolReadMessage = memo(function ToolReadMessage({
comment,
worktreePath,
onOpenFile,
}: ToolReadMessageProps) {
const { status, readOutput, filePath, hasOutput, isSuccess } = parseReadMetadata(comment);
const { status, readOutput, filePath, startLine, lineRange, hasOutput, isSuccess } =
parseReadMetadata(comment);
const autoExpanded = status === "running";
const { isExpanded, handleToggle } = useExpandState(status, autoExpanded);
// Navigate the editor to the line the agent read (offset). For a newly opened
// file the pending position is consumed on editor mount; for an already-open
// file no mount happens, so scroll the live editor directly.
const handleOpenFile = useCallback(
(path: string) => {
if (startLine && startLine > 0) {
setPendingCursorPosition(path, startLine, 1);
onOpenFile?.(path);
scrollEditorIfMounted(path, worktreePath ?? null, startLine, 1);
return;
}
onOpenFile?.(path);
},
[onOpenFile, startLine, worktreePath],
);
return (
<ExpandableRow
@@ -80,12 +114,17 @@ export const ToolReadMessage = memo(function ToolReadMessage({
{!isSuccess && <ReadStatusIcon status={status} />}
</span>
{filePath && (
<span className="min-w-0">
<span className="inline-flex items-baseline min-w-0">
<FilePathButton
filePath={filePath}
worktreePath={worktreePath}
onOpenFile={onOpenFile}
onOpenFile={onOpenFile ? handleOpenFile : undefined}
/>
{lineRange && (
<span className="font-mono text-xs text-muted-foreground/70 shrink-0">
{lineRange}
</span>
)}
</span>
)}
{readOutput?.truncated && (
@@ -1,10 +1,12 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import CodeMirror from "@uiw/react-codemirror";
import { vscodeDark } from "@uiw/codemirror-theme-vscode";
import { EditorView } from "@codemirror/view";
import type { Extension } from "@codemirror/state";
import { getCodeMirrorExtensionFromPath } from "@/lib/languages";
import { consumePendingCursorPosition } from "@/hooks/use-file-editors";
import { cn } from "@/lib/utils";
type FileViewerContentProps = {
@@ -20,6 +22,43 @@ export function FileViewerContent({ path, content, className }: FileViewerConten
extensions.push(langExt);
}
const viewRef = useRef<EditorView | null>(null);
// Consume a pending cursor position (set by chat read/edit file links before
// they open the file) and scroll CodeMirror so the target 1-based line is
// centered. No pending entry → no scroll, so normal file browsing stays at
// the top. Desktop's Monaco consumes the same entry; on mobile there is no
// Monaco, so this CodeMirror viewer must consume it instead.
const scrollToPendingLine = useCallback((view: EditorView, filePath: string) => {
const pending = consumePendingCursorPosition(filePath);
if (!pending) return;
const totalLines = view.state.doc.lines;
const clampedLine = Math.min(Math.max(pending.line, 1), totalLines);
const pos = view.state.doc.line(clampedLine).from;
view.dispatch({
selection: { anchor: pos },
effects: EditorView.scrollIntoView(pos, { y: "center" }),
});
}, []);
const handleCreateEditor = useCallback(
(view: EditorView) => {
viewRef.current = view;
scrollToPendingLine(view, path);
},
[path, scrollToPendingLine],
);
// The same FileViewerContent instance is reused when switching files (the
// path prop changes without a remount), and a pending entry may be set after
// the editor already mounted. Re-consume + scroll whenever path changes and
// the EditorView exists, so the second open of a different file still jumps.
useEffect(() => {
if (viewRef.current) {
scrollToPendingLine(viewRef.current, path);
}
}, [path, scrollToPendingLine]);
return (
<CodeMirror
value={content}
@@ -27,6 +66,7 @@ export function FileViewerContent({ path, content, className }: FileViewerConten
theme={vscodeDark}
extensions={extensions}
readOnly
onCreateEditor={handleCreateEditor}
basicSetup={{
lineNumbers: true,
foldGutter: true,
@@ -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);
});
});
+37
View File
@@ -18,6 +18,7 @@ import type { FileContentResponse } from "@/lib/types/backend";
import { useSaveDeleteActions } from "./use-file-save-delete";
import { PREVIEW_FILE_EDITOR_ID } from "@/lib/state/dockview-panel-actions";
import { useOpenFileWorkspaceSync } from "./file-editors-sync";
import { getMonacoInstance } from "@/components/editors/monaco/monaco-init";
// Module-level guard: ensures restoration only runs once across all hook instances
let _restoredSessionId: string | null = null;
@@ -39,6 +40,42 @@ export function consumePendingCursorPosition(
return pos;
}
/**
* Scroll an already-mounted Monaco editor for `path` to the given position.
* Returns true if an editor was found and scrolled. The editor's model URI is
* built as `${worktreePath}/${path}` (see use-monaco-editor-lsp), so the same
* `path` used for setPendingCursorPosition resolves the right editor here.
*
* Used for the already-open case: when a file link is clicked but its tab is
* already open, no editor mounts, so handleEditorDidMount never consumes the
* pending position — this scrolls the live editor instead (and consumes the
* pending entry so a later remount doesn't double-apply it).
*/
export function scrollEditorIfMounted(
path: string,
worktreePath: string | null,
line: number,
column: number,
): boolean {
const monaco = getMonacoInstance();
if (!monaco) return false;
const monacoPath = worktreePath ? `${worktreePath}/${path}` : path;
for (const editor of monaco.editor.getEditors()) {
const model = editor.getModel();
if (!model) continue;
const modelPath = model.uri.path;
if (modelPath === `/${monacoPath}` || modelPath === monacoPath) {
consumePendingCursorPosition(path);
editor.setPosition({ lineNumber: line, column });
editor.revealLineInCenter(line);
editor.focus();
return true;
}
}
return false;
}
/** Read openFiles from the store without subscribing to changes. */
function getOpenFiles() {
return useDockviewStore.getState().openFiles;
+1 -34
View File
@@ -5,42 +5,9 @@ import { useAppStore } from "@/components/state-provider";
import {
useFileEditors,
setPendingCursorPosition,
consumePendingCursorPosition,
scrollEditorIfMounted,
} from "@/hooks/use-file-editors";
import { lspClientManager } from "@/lib/lsp/lsp-client-manager";
import { getMonacoInstance } from "@/components/editors/monaco/monaco-init";
/**
* Try to scroll an already-mounted editor to the given position.
* Returns true if the editor was found and scrolled, false otherwise.
* If successful, consumes the pending cursor position so handleEditorDidMount
* doesn't double-apply it.
*/
function scrollEditorIfMounted(
relativePath: string,
worktreePath: string | null,
line: number,
column: number,
): boolean {
const monaco = getMonacoInstance();
if (!monaco) return false;
const monacoPath = worktreePath ? `${worktreePath}/${relativePath}` : relativePath;
for (const editor of monaco.editor.getEditors()) {
const model = editor.getModel();
if (!model) continue;
const modelPath = model.uri.path;
if (modelPath === `/${monacoPath}` || modelPath === monacoPath) {
consumePendingCursorPosition(relativePath);
editor.setPosition({ lineNumber: line, column });
editor.revealLineInCenter(line);
editor.focus();
return true;
}
}
return false;
}
/**
* Connects LSP Go-to-Definition / Find References navigation to dockview file tabs.