Show resolved file paths in agent file-link tooltips (#1088)

* fix: show resolved file paths in agent file-link tooltips

Bare filenames in agent messages now resolve to their full workspace
path on hover. Also raises the daemon's directory-suggestion scan
depth so files in deeper package layouts are reachable.

* fix(app): keep file-link wrapper stable to avoid layout shift on resolve

* fix(app): resolve file links on hover instead of at render time

useQuery was firing the daemon RPC for every ambiguous file reference
the moment a message rendered, fanning out a wave of requests on chat
scroll. Switch to enabled: false + prefetchQuery on hover so RPCs are
driven by user intent. Sync-resolvable refs (directFile, external)
still seed via initialData and render with no RPC.

Also memo on source primitives rather than identity so identical-
content sources constructed inline upstream don't bust the memo every
render.

* Redesign assistant file link resolution

* Stabilize assistant file link handlers
This commit is contained in:
Mohamed Boudra
2026-05-19 15:32:48 +08:00
committed by GitHub
parent b41cb72da0
commit 0f6641c8c0
11 changed files with 1165 additions and 1079 deletions
@@ -74,7 +74,10 @@ import {
type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest,
} from "./use-bottom-anchor-controller";
import { normalizeInlinePathTarget } from "@/assistant-file-links";
import {
AssistantFileLinkResolverProvider,
normalizeInlinePathTarget,
} from "@/assistant-file-links";
import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
@@ -83,6 +86,7 @@ import {
} 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";
@@ -322,7 +326,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
setExpandedInlineToolCallIds(new Set());
}, [agentId]);
const handleInlinePathPress = useCallback(
const handleInlinePathPress = useStableEvent(
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
if (!target.path) {
return;
@@ -377,25 +381,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
checkout,
});
},
[
agent.cwd,
agent.projectPlacement?.checkout?.isGit,
isMobile,
openFileExplorerForCheckout,
onOpenWorkspaceFile,
requestDirectoryListing,
resolvedServerId,
setExplorerTabForCheckout,
workspaceId,
],
);
const handleToolCallOpenFile = useCallback(
(filePath: string) => {
handleInlinePathPress({ raw: filePath, path: filePath }, "main");
},
[handleInlinePathPress],
);
const handleToolCallOpenFile = useStableEvent((filePath: string) => {
handleInlinePathPress({ raw: filePath, path: filePath }, "main");
});
const baseRenderModel = useMemo(() => {
return buildAgentStreamRenderModel({
@@ -510,19 +500,25 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
belowItem,
});
return (
<AssistantMessage
message={item.text}
timestamp={item.timestamp.getTime()}
onInlinePathPress={handleInlinePathPress}
workspaceRoot={workspaceRoot}
serverId={serverId}
<AssistantFileLinkResolverProvider
client={client}
serverId={resolvedServerId}
workspaceRoot={workspaceRoot}
onOpenWorkspaceFile={handleInlinePathPress}
toast={toast}
spacing={spacing}
/>
>
<AssistantMessage
message={item.text}
timestamp={item.timestamp.getTime()}
workspaceRoot={workspaceRoot}
serverId={resolvedServerId}
client={client}
spacing={spacing}
/>
</AssistantFileLinkResolverProvider>
);
},
[handleInlinePathPress, streamRenderStrategy, workspaceRoot, serverId, client, toast],
[client, handleInlinePathPress, resolvedServerId, streamRenderStrategy, toast, workspaceRoot],
);
const renderThoughtItem = useCallback(
+15 -59
View File
@@ -68,9 +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 type { OpenFileDisposition } from "@/workspace/file-open";
import { getMarkdownListMarker, getMarkdownNextSiblingType } from "@/utils/markdown-list";
import type { ToastApi } from "@/components/toast-host";
import { useStableEvent } from "@/hooks/use-stable-event";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
@@ -92,12 +91,11 @@ import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details";
import {
AssistantInlineCodePathLink,
classifyAssistantFileLink,
type AssistantFileLinkSource,
AssistantMarkdownCodeLink,
AssistantMarkdownLink,
type InlinePathTarget,
useAssistantFileLinkResolver,
useAssistantFileLinkActions,
} from "@/assistant-file-links";
import { getCompactionMarkerLabel } from "./message-compaction-label";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
@@ -717,11 +715,9 @@ export const LiveElapsed = memo(function LiveElapsed({
interface AssistantMessageProps {
message: string;
timestamp: number;
onInlinePathPress?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
workspaceRoot?: string;
serverId?: string;
client?: DaemonClient | null;
toast?: ToastApi | null;
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
}
@@ -1563,11 +1559,9 @@ function MarkdownListView({ baseStyle, marginBottom, children }: MarkdownListVie
export const AssistantMessage = memo(function AssistantMessage({
message,
timestamp: _timestamp,
onInlinePathPress,
workspaceRoot,
serverId,
client,
toast,
spacing = "default",
}: AssistantMessageProps) {
const markdownParser = useMemo(() => {
@@ -1583,36 +1577,14 @@ export const AssistantMessage = memo(function AssistantMessage({
return parser;
}, []);
const fileLinkResolver = useAssistantFileLinkResolver({
client,
serverId,
workspaceRoot,
onOpenWorkspaceFile: onInlinePathPress,
toast,
const fileLinkActions = useAssistantFileLinkActions();
const handleMarkdownLinkPress = useStableEvent((url: string) => {
fileLinkActions.open({ href: url }, "main");
// react-native-markdown-display opens the link itself when this returns true.
// We already handled it above, so return false to avoid duplicate opens.
return false;
});
const handleLinkPress = useCallback(
(source: AssistantFileLinkSource, disposition: OpenFileDisposition) => {
fileLinkResolver.open({ source, disposition });
},
[fileLinkResolver],
);
const handleLinkPrefetch = useCallback(
(source: AssistantFileLinkSource) => {
fileLinkResolver.prefetch({ source });
},
[fileLinkResolver],
);
const handleMarkdownLinkPress = useCallback(
(url: string) => {
fileLinkResolver.open({ source: { href: url }, disposition: "main" });
// react-native-markdown-display opens the link itself when this returns true.
// We already handled it above, so return false to avoid duplicate opens.
return false;
},
[fileLinkResolver],
);
const markdownRules = useMemo<RenderRules>(() => {
return {
text: (
@@ -1684,12 +1656,13 @@ export const AssistantMessage = memo(function AssistantMessage({
) => {
const content = node.content ?? "";
const isLinkedInlineCode = nodeHasParentType(parent, "link");
const inlineCodeFileLink = classifyAssistantFileLink(content, { workspaceRoot });
const inlineCodeSource: AssistantFileLinkSource = {
href: content,
text: content,
sourceType: "inline-code",
};
const shouldResolveInlinePath =
onInlinePathPress &&
!isLinkedInlineCode &&
inlineCodeFileLink &&
inlineCodeFileLink.kind !== "external";
!isLinkedInlineCode && fileLinkActions.canResolveFile(inlineCodeSource);
if (shouldResolveInlinePath) {
return (
@@ -1699,9 +1672,6 @@ export const AssistantMessage = memo(function AssistantMessage({
inheritedStyles={inheritedStyles}
codeInlineStyle={styles.code_inline}
linkStyle={styles.link}
onPress={handleLinkPress}
onPrefetch={handleLinkPrefetch}
workspaceRoot={workspaceRoot}
/>
);
}
@@ -1719,9 +1689,6 @@ export const AssistantMessage = memo(function AssistantMessage({
inheritedStyles={inheritedStyles}
codeInlineStyle={styles.code_inline}
linkStyle={styles.link}
onPress={handleLinkPress}
onPrefetch={handleLinkPrefetch}
workspaceRoot={workspaceRoot}
>
{content}
</AssistantMarkdownCodeLink>
@@ -1800,9 +1767,6 @@ export const AssistantMessage = memo(function AssistantMessage({
key={node.key}
source={getMarkdownLinkSource(node)}
style={styles.link}
onPress={handleLinkPress}
onPrefetch={handleLinkPrefetch}
workspaceRoot={workspaceRoot}
>
{Children.map(children, (child) => {
if (!isValidElement(child)) return child;
@@ -1841,15 +1805,7 @@ export const AssistantMessage = memo(function AssistantMessage({
);
},
};
}, [
client,
handleLinkPrefetch,
handleLinkPress,
markdownParser,
onInlinePathPress,
serverId,
workspaceRoot,
]);
}, [client, fileLinkActions, markdownParser, serverId, workspaceRoot]);
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
const keyedBlocks = useMemo(