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
@@ -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,