Support line ranges in assistant file links (#1067)

This commit is contained in:
Mohamed Boudra
2026-05-17 22:55:46 +08:00
committed by GitHub
parent efc9c2d345
commit ee44b536f5
29 changed files with 504 additions and 115 deletions
@@ -74,11 +74,15 @@ import {
type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest,
} from "./use-bottom-anchor-controller";
import { normalizeInlinePathTarget } from "@/utils/inline-path";
import type { OpenFileDisposition } from "@/utils/workspace-file-open";
import { normalizeInlinePathTarget } from "@/assistant-file-links";
import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
type OpenFileDisposition,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
@@ -243,7 +247,7 @@ export interface AgentStreamViewProps {
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady?: boolean;
toast?: ToastApi | null;
onOpenWorkspaceFile?: (input: { filePath: string; disposition: OpenFileDisposition }) => void;
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}
const EMPTY_STREAM_HEAD: StreamItem[] = [];
@@ -303,12 +307,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
agentId,
toast,
});
const openWorkspaceFile = useStableEvent(function openWorkspaceFile(input: {
filePath: string;
disposition: OpenFileDisposition;
}) {
onOpenWorkspaceFile?.(input);
});
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
// tracked in react-native-reanimated#8422.
const shouldDisableEntryExitAnimations = Platform.OS === "android";
@@ -336,8 +334,20 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}
if (normalized.file) {
const location = normalizeWorkspaceFileLocation({
path: normalized.file,
lineStart: target.lineStart,
lineEnd: target.lineEnd,
});
if (!location) {
return;
}
if (onOpenWorkspaceFile) {
openWorkspaceFile({ filePath: normalized.file, disposition });
onOpenWorkspaceFile({
location,
disposition,
});
return;
}
@@ -345,7 +355,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
navigateToPreparedWorkspaceTab({
serverId: resolvedServerId,
workspaceId,
target: { kind: "file", path: normalized.file },
target: createWorkspaceFileTabTarget(location),
});
}
return;
@@ -376,7 +386,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
requestDirectoryListing,
resolvedServerId,
setExplorerTabForCheckout,
openWorkspaceFile,
workspaceId,
],
);
@@ -1,346 +0,0 @@
import {
useCallback,
useMemo,
useState,
type CSSProperties,
type ReactNode,
type MouseEvent,
} from "react";
import {
Pressable,
Text,
View,
type StyleProp,
type TextStyle,
type ViewStyle,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { isNative, isWeb } from "@/constants/platform";
import { classifyAssistantFileLink, type InlinePathTarget } from "@/utils/inline-path";
import type { AssistantFileLinkSource } from "@/utils/assistant-file-link-resolver";
import type { OpenFileDisposition } from "@/utils/workspace-file-open";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
interface AssistantInlinePathLinkProps {
content: string;
parsed: InlinePathTarget;
onPress: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
workspaceRoot?: string;
style: StyleProp<TextStyle>;
}
export function AssistantInlinePathLink({
content,
parsed,
onPress,
workspaceRoot,
style,
}: AssistantInlinePathLinkProps) {
const handlePress = useCallback(() => onPress(parsed, "main"), [onPress, parsed]);
const handleAnchorClickCapture = useCallback(
(event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
if (!isModifiedOpenEvent(event)) {
return;
}
event.stopPropagation();
onPress(parsed, "side");
},
[onPress, parsed],
);
if (!isNative) {
return (
<FileLinkHoverTooltip filePath={formatInlinePathTargetForTooltip(parsed, workspaceRoot)}>
<a
href={parsed.path}
onClickCapture={handleAnchorClickCapture}
onAuxClickCapture={preventAnchorNavigation}
style={LINK_ANCHOR_STYLE}
>
<Text onPress={handlePress} selectable={isWeb ? undefined : false} style={style}>
{content}
</Text>
</a>
</FileLinkHoverTooltip>
);
}
return (
<Text onPress={handlePress} selectable={isWeb ? undefined : false} style={style}>
{content}
</Text>
);
}
interface AssistantMarkdownLinkProps {
source: AssistantFileLinkSource;
style: StyleProp<TextStyle>;
onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
onPrefetch: (source: AssistantFileLinkSource) => void;
workspaceRoot?: string;
children: ReactNode;
}
export function AssistantMarkdownLink({
source,
style,
onPress,
onPrefetch,
workspaceRoot,
children,
}: AssistantMarkdownLinkProps) {
const [hovered, setHovered] = useState(false);
const href = source.href;
const handlePress = useCallback(() => onPress(source, "main"), [onPress, source]);
const handleAnchorClickCapture = useCallback(
(event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
if (!isModifiedOpenEvent(event)) {
return;
}
event.stopPropagation();
onPress(source, "side");
},
[onPress, source],
);
const handlePrefetch = useCallback(() => onPrefetch(source), [onPrefetch, source]);
const handleHoverIn = useCallback(() => {
setHovered(true);
handlePrefetch();
}, [handlePrefetch]);
const handleHoverOut = useCallback(() => setHovered(false), []);
const hoveredTextStyle = useMemo<StyleProp<TextStyle>>(
() => [style, hovered && { textDecorationLine: "underline" as const }],
[style, hovered],
);
const tooltipFilePath = useMemo(
() => getMarkdownLinkTooltipFilePath(source.href, workspaceRoot),
[source.href, workspaceRoot],
);
if (isNative) {
return (
<Text accessibilityRole="link" onPress={handlePress} style={style}>
{children}
</Text>
);
}
const anchor = (
<a
href={href}
onClickCapture={handleAnchorClickCapture}
onAuxClickCapture={preventAnchorNavigation}
style={LINK_ANCHOR_STYLE}
>
<Pressable
accessibilityRole="link"
onPress={handlePress}
onFocus={handlePrefetch}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
>
<Text style={hoveredTextStyle}>{children}</Text>
</Pressable>
</a>
);
if (tooltipFilePath) {
return <FileLinkHoverTooltip filePath={tooltipFilePath}>{anchor}</FileLinkHoverTooltip>;
}
return anchor;
}
interface AssistantMarkdownCodeLinkProps {
source: AssistantFileLinkSource;
inheritedStyles: TextStyle;
codeInlineStyle: TextStyle;
linkStyle: TextStyle;
onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
onPrefetch: (source: AssistantFileLinkSource) => void;
workspaceRoot?: string;
children: ReactNode;
}
export function AssistantMarkdownCodeLink({
source,
inheritedStyles,
codeInlineStyle,
linkStyle,
onPress,
onPrefetch,
workspaceRoot,
children,
}: AssistantMarkdownCodeLinkProps) {
const style = useMemo(
() => [inheritedStyles, codeInlineStyle, linkStyle],
[inheritedStyles, codeInlineStyle, linkStyle],
);
return (
<AssistantMarkdownLink
source={source}
style={style}
onPress={onPress}
onPrefetch={onPrefetch}
workspaceRoot={workspaceRoot}
>
{children}
</AssistantMarkdownLink>
);
}
interface AssistantInlineCodePathLinkProps {
content: string;
inheritedStyles: TextStyle;
codeInlineStyle: TextStyle;
linkStyle: TextStyle;
onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
onPrefetch: (source: AssistantFileLinkSource) => void;
workspaceRoot?: string;
}
export function AssistantInlineCodePathLink({
content,
inheritedStyles,
codeInlineStyle,
linkStyle,
onPress,
onPrefetch,
workspaceRoot,
}: AssistantInlineCodePathLinkProps) {
const source = useMemo<AssistantFileLinkSource>(
() => ({
href: content,
text: content,
sourceType: "inline-code",
}),
[content],
);
return (
<AssistantMarkdownCodeLink
source={source}
inheritedStyles={inheritedStyles}
codeInlineStyle={codeInlineStyle}
linkStyle={linkStyle}
onPress={onPress}
onPrefetch={onPrefetch}
workspaceRoot={workspaceRoot}
>
{content}
</AssistantMarkdownCodeLink>
);
}
function getMarkdownLinkTooltipFilePath(
href: string,
workspaceRoot: string | undefined,
): string | null {
const classification = classifyAssistantFileLink(href, { workspaceRoot });
if (classification?.kind !== "directFile") {
return null;
}
return formatInlinePathTargetForTooltip(classification.target, workspaceRoot);
}
function formatInlinePathTargetForTooltip(
target: InlinePathTarget,
workspaceRoot: string | undefined,
): string {
let result = relativizePathToWorkspace(target.path, workspaceRoot);
if (target.lineStart) {
result += `:${target.lineStart}`;
if (target.lineEnd && target.lineEnd !== target.lineStart) {
result += `-${target.lineEnd}`;
}
}
return result;
}
function relativizePathToWorkspace(filePath: string, workspaceRoot: string | undefined): string {
if (!workspaceRoot) {
return filePath;
}
const root = workspaceRoot.replace(/\/+$/, "");
if (!root) {
return filePath;
}
if (filePath === root) {
return ".";
}
const prefix = `${root}/`;
if (filePath.startsWith(prefix)) {
return filePath.slice(prefix.length);
}
return filePath;
}
const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = {
// RN doesn't type "inline-flex" but RN-web honors it at runtime, which keeps
// the tooltip wrapper from breaking inline link flow.
display: "inline-flex" as ViewStyle["display"],
};
const FILE_LINK_TOOLTIP_MOD_KEYS = ["mod"];
function FileLinkHoverTooltip({ filePath, children }: { filePath: string; children: ReactNode }) {
if (!isWeb) {
return children;
}
return (
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>
<View style={FILE_LINK_TOOLTIP_TRIGGER_STYLE}>{children}</View>
</TooltipTrigger>
<TooltipContent side="top" align="start" maxWidth={520}>
<View style={styles.tooltipBody}>
<Text selectable={false} style={styles.tooltipPath}>
{filePath}
</Text>
<View style={styles.tooltipHintRow}>
<Shortcut keys={FILE_LINK_TOOLTIP_MOD_KEYS} />
<Text selectable={false} style={styles.tooltipHintText}>
click for side pane
</Text>
</View>
</View>
</TooltipContent>
</Tooltip>
);
}
const LINK_ANCHOR_STYLE: CSSProperties = {
display: "contents",
color: "inherit",
textDecoration: "none",
};
function preventAnchorNavigation(event: MouseEvent<HTMLAnchorElement>): void {
event.preventDefault();
}
function isModifiedOpenEvent(event: MouseEvent<HTMLElement>): boolean {
return event.metaKey || event.ctrlKey;
}
const styles = StyleSheet.create((theme) => ({
tooltipBody: {
gap: theme.spacing[1],
},
tooltipPath: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
tooltipHintRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
tooltipHintText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
}));
+81 -11
View File
@@ -1,4 +1,4 @@
import React, { useMemo, useRef } from "react";
import React, { useEffect, useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import type { FileReadResult } from "@server/client/daemon-client";
import Markdown, { MarkdownIt } from "react-native-markdown-display";
@@ -26,11 +26,13 @@ import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-ur
import { persistAttachmentFromBytes } from "@/attachments/service";
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
interface CodeLineProps {
tokens: HighlightToken[];
lineNumber: number;
gutterWidth: number;
highlighted: boolean;
}
interface FilePreviewBodyProps {
@@ -38,7 +40,7 @@ interface FilePreviewBodyProps {
isLoading: boolean;
showDesktopWebScrollbar: boolean;
isMobile: boolean;
filePath: string;
location: WorkspaceFileLocation;
imagePreviewUri: string | null;
}
@@ -50,6 +52,11 @@ function trimNonEmpty(value: string | null | undefined): string | null {
return trimmed.length > 0 ? trimmed : null;
}
interface FileLineSelection {
lineStart: number;
lineEnd: number;
}
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
return `${size} B`;
@@ -92,14 +99,38 @@ async function createFilePanePreview(file: FileReadResult | null): Promise<{
};
}
const CodeLine = React.memo(function CodeLine({ tokens, lineNumber, gutterWidth }: CodeLineProps) {
function clampLineSelection(input: {
lineStart?: number;
lineEnd?: number;
lineCount: number;
}): FileLineSelection | null {
if (!input.lineStart || input.lineStart <= 0 || input.lineCount <= 0) {
return null;
}
const lineStart = Math.min(Math.floor(input.lineStart), input.lineCount);
const rawLineEnd =
input.lineEnd && input.lineEnd >= input.lineStart ? input.lineEnd : input.lineStart;
const lineEnd = Math.min(Math.floor(rawLineEnd), input.lineCount);
return { lineStart, lineEnd: Math.max(lineStart, lineEnd) };
}
const CodeLine = React.memo(function CodeLine({
tokens,
lineNumber,
gutterWidth,
highlighted,
}: CodeLineProps) {
const gutterStyle = useMemo(() => [codeLineStyles.gutter, { width: gutterWidth }], [gutterWidth]);
const lineStyle = useMemo(
() => [codeLineStyles.line, highlighted && codeLineStyles.highlightedLine],
[highlighted],
);
const keyedTokens = useMemo(
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
[tokens],
);
return (
<View style={codeLineStyles.line}>
<View style={lineStyle}>
<View style={gutterStyle}>
<Text numberOfLines={1} style={codeLineStyles.gutterText}>
{String(lineNumber)}
@@ -126,6 +157,9 @@ const codeLineStyles = StyleSheet.create((theme) => ({
line: {
flexDirection: "row",
},
highlightedLine: {
backgroundColor: theme.colors.accentBorder,
},
gutter: {
alignItems: "flex-end",
paddingRight: theme.spacing[3],
@@ -152,13 +186,15 @@ function FilePreviewBody({
isLoading,
showDesktopWebScrollbar,
isMobile,
filePath,
location,
imagePreviewUri,
}: FilePreviewBodyProps) {
const { theme } = useUnistyles();
const filePath = location.path;
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
const isMarkdownFile = preview?.kind === "text" && isRenderedMarkdownFile(filePath);
const isMarkdownFile =
preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart;
const previewScrollRef = useRef<RNScrollView>(null);
const webScrollbarStyle = useWebScrollbarStyle();
@@ -178,12 +214,36 @@ function FilePreviewBody({
if (!highlightedLines) return 0;
return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.sm);
}, [highlightedLines, theme.fontSize.sm]);
const lineHeight = theme.fontSize.sm * 1.45;
const lineSelection = useMemo(() => {
if (!highlightedLines) {
return null;
}
return clampLineSelection({
lineStart: location.lineStart,
lineEnd: location.lineEnd,
lineCount: highlightedLines.length,
});
}, [highlightedLines, location.lineEnd, location.lineStart]);
const imageSource = useMemo(
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
[imagePreviewUri],
);
useEffect(() => {
if (!lineSelection) {
return;
}
const timeout = setTimeout(() => {
previewScrollRef.current?.scrollTo({
y: Math.max(0, (lineSelection.lineStart - 1) * lineHeight),
animated: false,
});
}, 0);
return () => clearTimeout(timeout);
}, [lineHeight, lineSelection]);
if (isLoading && !preview) {
return (
<View style={styles.centerState}>
@@ -233,7 +293,17 @@ function FilePreviewBody({
const codeLines = (
<View>
{keyedLines.map(({ key, tokens, lineNumber }) => (
<CodeLine key={key} tokens={tokens} lineNumber={lineNumber} gutterWidth={gutterWidth} />
<CodeLine
key={key}
tokens={tokens}
lineNumber={lineNumber}
gutterWidth={gutterWidth}
highlighted={
Boolean(lineSelection) &&
lineNumber >= (lineSelection?.lineStart ?? 0) &&
lineNumber <= (lineSelection?.lineEnd ?? 0)
}
/>
))}
</View>
);
@@ -312,18 +382,18 @@ function FilePreviewBody({
export function FilePane({
serverId,
workspaceRoot,
filePath,
location,
}: {
serverId: string;
workspaceRoot: string;
filePath: string;
location: WorkspaceFileLocation;
}) {
const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = isWeb && !isMobile;
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const normalizedFilePath = useMemo(() => trimNonEmpty(filePath), [filePath]);
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
const query = useQuery({
queryKey: ["workspaceFile", serverId, normalizedWorkspaceRoot, normalizedFilePath],
@@ -366,7 +436,7 @@ export function FilePane({
isLoading={query.isFetching}
showDesktopWebScrollbar={showDesktopWebScrollbar}
isMobile={isMobile}
filePath={filePath}
location={location}
imagePreviewUri={imagePreviewUri}
/>
</View>
+12 -7
View File
@@ -68,11 +68,8 @@ import type { AgentAttachment } from "@server/shared/messages";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import { buildToolCallPresentation } from "@/tool-calls/presentation";
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
import type { OpenFileDisposition } from "@/utils/workspace-file-open";
import type { OpenFileDisposition } from "@/workspace/file-open";
import { getMarkdownListMarker, getMarkdownNextSiblingType } from "@/utils/markdown-list";
import { type AssistantFileLinkSource } from "@/utils/assistant-file-link-resolver";
import { useAssistantFileLinkResolver } from "@/hooks/use-assistant-file-link-resolver";
import type { ToastApi } from "@/components/toast-host";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
@@ -95,15 +92,19 @@ import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details";
import {
AssistantInlineCodePathLink,
classifyAssistantFileLink,
type AssistantFileLinkSource,
AssistantMarkdownCodeLink,
AssistantMarkdownLink,
} from "./assistant-file-link";
type InlinePathTarget,
useAssistantFileLinkResolver,
} from "@/assistant-file-links";
import { getCompactionMarkerLabel } from "./message-compaction-label";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
import type { DaemonClient } from "@server/client/daemon-client";
import { isWeb, isNative } from "@/constants/platform";
export type { InlinePathTarget } from "@/utils/inline-path";
export type { InlinePathTarget } from "@/assistant-file-links";
type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
@@ -1683,8 +1684,12 @@ export const AssistantMessage = memo(function AssistantMessage({
) => {
const content = node.content ?? "";
const isLinkedInlineCode = nodeHasParentType(parent, "link");
const inlineCodeFileLink = classifyAssistantFileLink(content, { workspaceRoot });
const shouldResolveInlinePath =
onInlinePathPress && !isLinkedInlineCode && parseInlinePathToken(content);
onInlinePathPress &&
!isLinkedInlineCode &&
inlineCodeFileLink &&
inlineCodeFileLink.kind !== "external";
if (shouldResolveInlinePath) {
return (
@@ -67,7 +67,7 @@ import {
type WorkspaceLayout,
} from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
import { workspaceTabTargetsEqual } from "@/utils/workspace-tab-identity";
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
import { isNative } from "@/constants/platform";
interface SplitContainerProps {