fix: open agent file links that carry line-range selectors (#1412)
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user