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
@@ -1,6 +1,5 @@
export { export {
AssistantInlineCodePathLink, AssistantInlineCodePathLink,
AssistantInlinePathLink,
AssistantMarkdownCodeLink, AssistantMarkdownCodeLink,
AssistantMarkdownLink, AssistantMarkdownLink,
} from "./link"; } from "./link";
@@ -9,5 +8,9 @@ export {
normalizeInlinePathTarget, normalizeInlinePathTarget,
type InlinePathTarget, type InlinePathTarget,
} from "./parse"; } from "./parse";
export {
AssistantFileLinkResolverProvider,
type AssistantFileLinkResolverProviderProps,
} from "./provider";
export type { AssistantFileLinkSource } from "./resolver"; export type { AssistantFileLinkSource } from "./resolver";
export { useAssistantFileLinkResolver } from "./use-resolver"; export { useAssistantFileLinkActions } from "./use-file-link";
+88 -184
View File
@@ -1,11 +1,4 @@
import { import { useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
useCallback,
useMemo,
useState,
type CSSProperties,
type ReactNode,
type MouseEvent,
} from "react";
import { import {
Pressable, Pressable,
Text, Text,
@@ -16,128 +9,66 @@ import {
} from "react-native"; } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { isNative, isWeb } from "@/constants/platform"; import { isNative, isWeb } from "@/constants/platform";
import type { OpenFileDisposition } from "@/workspace/file-open";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { classifyAssistantFileLink, type InlinePathTarget } from "./parse"; import { useStableEvent } from "@/hooks/use-stable-event";
import { useAssistantFileLinkResolverContext } from "./provider";
import type { AssistantFileLinkSource } from "./resolver"; import type { AssistantFileLinkSource } from "./resolver";
import { useFileLink } from "./use-file-link";
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 { interface AssistantMarkdownLinkProps {
source: AssistantFileLinkSource; source: AssistantFileLinkSource;
style: StyleProp<TextStyle>; style: StyleProp<TextStyle>;
onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
onPrefetch: (source: AssistantFileLinkSource) => void;
workspaceRoot?: string;
children: ReactNode; children: ReactNode;
} }
export function AssistantMarkdownLink({ export function AssistantMarkdownLink({ source, style, children }: AssistantMarkdownLinkProps) {
source,
style,
onPress,
onPrefetch,
workspaceRoot,
children,
}: AssistantMarkdownLinkProps) {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const href = source.href; const { target, onHoverIn, onPress, onAuxPress } = useFileLink(source);
const handlePress = useCallback(() => onPress(source, "main"), [onPress, source]); const { configRef } = useAssistantFileLinkResolverContext();
const handleAnchorClickCapture = useCallback( const workspaceRoot = configRef.current.workspaceRoot;
(event: MouseEvent<HTMLAnchorElement>) => { const tooltipPath = useMemo(
event.preventDefault(); () => (target ? formatInlinePathTargetForTooltip(target, workspaceRoot) : null),
if (!isModifiedOpenEvent(event)) { [target, workspaceRoot],
return;
}
event.stopPropagation();
onPress(source, "side");
},
[onPress, source],
); );
const handlePrefetch = useCallback(() => onPrefetch(source), [onPrefetch, source]); const handleAnchorClickCapture = useStableEvent((event: MouseEvent<HTMLAnchorElement>) => {
const handleHoverIn = useCallback(() => { event.preventDefault();
if (!isModifiedOpenEvent(event)) {
return;
}
event.stopPropagation();
onAuxPress();
});
const handleHoverIn = useStableEvent(() => {
setHovered(true); setHovered(true);
handlePrefetch(); onHoverIn();
}, [handlePrefetch]); });
const handleHoverOut = useCallback(() => setHovered(false), []); const handleHoverOut = useStableEvent(() => setHovered(false));
const hoveredTextStyle = useMemo<StyleProp<TextStyle>>( const hoveredTextStyle = useMemo<StyleProp<TextStyle>>(
() => [style, hovered && { textDecorationLine: "underline" as const }], () => [style, hovered && { textDecorationLine: "underline" as const }],
[style, hovered], [style, hovered],
); );
const tooltipFilePath = useMemo(
() => getMarkdownLinkTooltipFilePath(source.href, workspaceRoot),
[source.href, workspaceRoot],
);
if (isNative) { if (isNative) {
return ( return (
<Text accessibilityRole="link" onPress={handlePress} style={style}> <FileLinkHoverTooltip filePath={tooltipPath}>
{children} <Text accessibilityRole="link" onPress={onPress} style={style}>
</Text> {children}
</Text>
</FileLinkHoverTooltip>
); );
} }
const anchor = ( const anchor = (
<a <a
href={href} href={source.href}
onClickCapture={handleAnchorClickCapture} onClickCapture={handleAnchorClickCapture}
onAuxClickCapture={preventAnchorNavigation} onAuxClickCapture={preventAnchorNavigation}
style={LINK_ANCHOR_STYLE} style={LINK_ANCHOR_STYLE}
> >
<Pressable <Pressable
accessibilityRole="link" accessibilityRole="link"
onPress={handlePress} onPress={onPress}
onFocus={handlePrefetch}
onHoverIn={handleHoverIn} onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut} onHoverOut={handleHoverOut}
> >
@@ -146,10 +77,7 @@ export function AssistantMarkdownLink({
</a> </a>
); );
if (tooltipFilePath) { return <FileLinkHoverTooltip filePath={tooltipPath}>{anchor}</FileLinkHoverTooltip>;
return <FileLinkHoverTooltip filePath={tooltipFilePath}>{anchor}</FileLinkHoverTooltip>;
}
return anchor;
} }
interface AssistantMarkdownCodeLinkProps { interface AssistantMarkdownCodeLinkProps {
@@ -157,9 +85,6 @@ interface AssistantMarkdownCodeLinkProps {
inheritedStyles: TextStyle; inheritedStyles: TextStyle;
codeInlineStyle: TextStyle; codeInlineStyle: TextStyle;
linkStyle: TextStyle; linkStyle: TextStyle;
onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
onPrefetch: (source: AssistantFileLinkSource) => void;
workspaceRoot?: string;
children: ReactNode; children: ReactNode;
} }
@@ -168,9 +93,6 @@ export function AssistantMarkdownCodeLink({
inheritedStyles, inheritedStyles,
codeInlineStyle, codeInlineStyle,
linkStyle, linkStyle,
onPress,
onPrefetch,
workspaceRoot,
children, children,
}: AssistantMarkdownCodeLinkProps) { }: AssistantMarkdownCodeLinkProps) {
const style = useMemo( const style = useMemo(
@@ -178,74 +100,14 @@ export function AssistantMarkdownCodeLink({
[inheritedStyles, codeInlineStyle, linkStyle], [inheritedStyles, codeInlineStyle, linkStyle],
); );
return ( return (
<AssistantMarkdownLink <AssistantMarkdownLink source={source} style={style}>
source={source}
style={style}
onPress={onPress}
onPrefetch={onPrefetch}
workspaceRoot={workspaceRoot}
>
{children} {children}
</AssistantMarkdownLink> </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( function formatInlinePathTargetForTooltip(
target: InlinePathTarget, target: { path: string; lineStart?: number; lineEnd?: number },
workspaceRoot: string | undefined, workspaceRoot: string | undefined,
): string { ): string {
let result = relativizePathToWorkspace(target.path, workspaceRoot); let result = relativizePathToWorkspace(target.path, workspaceRoot);
@@ -276,6 +138,40 @@ function relativizePathToWorkspace(filePath: string, workspaceRoot: string | und
return filePath; return filePath;
} }
interface AssistantInlineCodePathLinkProps {
content: string;
inheritedStyles: TextStyle;
codeInlineStyle: TextStyle;
linkStyle: TextStyle;
}
export function AssistantInlineCodePathLink({
content,
inheritedStyles,
codeInlineStyle,
linkStyle,
}: AssistantInlineCodePathLinkProps) {
const source = useMemo<AssistantFileLinkSource>(
() => ({
href: content,
text: content,
sourceType: "inline-code",
}),
[content],
);
return (
<AssistantMarkdownCodeLink
source={source}
inheritedStyles={inheritedStyles}
codeInlineStyle={codeInlineStyle}
linkStyle={linkStyle}
>
{content}
</AssistantMarkdownCodeLink>
);
}
const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = { const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = {
// RN doesn't type "inline-flex" but RN-web honors it at runtime, which keeps // RN doesn't type "inline-flex" but RN-web honors it at runtime, which keeps
// the tooltip wrapper from breaking inline link flow. // the tooltip wrapper from breaking inline link flow.
@@ -284,7 +180,13 @@ const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = {
const FILE_LINK_TOOLTIP_MOD_KEYS = ["mod"]; const FILE_LINK_TOOLTIP_MOD_KEYS = ["mod"];
function FileLinkHoverTooltip({ filePath, children }: { filePath: string; children: ReactNode }) { function FileLinkHoverTooltip({
filePath,
children,
}: {
filePath: string | null;
children: ReactNode;
}) {
if (!isWeb) { if (!isWeb) {
return children; return children;
} }
@@ -293,19 +195,21 @@ function FileLinkHoverTooltip({ filePath, children }: { filePath: string; childr
<TooltipTrigger asChild> <TooltipTrigger asChild>
<View style={FILE_LINK_TOOLTIP_TRIGGER_STYLE}>{children}</View> <View style={FILE_LINK_TOOLTIP_TRIGGER_STYLE}>{children}</View>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="start" maxWidth={520}> {filePath ? (
<View style={styles.tooltipBody}> <TooltipContent side="top" align="start" maxWidth={520}>
<Text selectable={false} style={styles.tooltipPath}> <View style={styles.tooltipBody}>
{filePath} <Text selectable={false} style={styles.tooltipPath}>
</Text> {filePath}
<View style={styles.tooltipHintRow}>
<Shortcut keys={FILE_LINK_TOOLTIP_MOD_KEYS} />
<Text selectable={false} style={styles.tooltipHintText}>
click for side pane
</Text> </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> </View>
</View> </TooltipContent>
</TooltipContent> ) : null}
</Tooltip> </Tooltip>
); );
} }
@@ -0,0 +1,87 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
type MutableRefObject,
type ReactNode,
} from "react";
import React from "react";
import type { ToastApi } from "@/components/toast-host";
import type { OpenFileDisposition } from "@/workspace/file-open";
import type { InlinePathTarget } from "./parse";
import type { AssistantFileLinkContext, GetDirectorySuggestions } from "./resolver";
export interface AssistantFileLinkDaemonClient {
getDirectorySuggestions: GetDirectorySuggestions;
}
export interface AssistantFileLinkResolverConfig {
client?: AssistantFileLinkDaemonClient | null;
serverId?: string;
workspaceRoot?: string;
onOpenWorkspaceFile?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
toast?: ToastApi | null;
}
export interface AssistantFileLinkResolverProviderProps extends AssistantFileLinkResolverConfig {
children: ReactNode;
}
export interface AssistantFileLinkResolverContextValue {
configRef: MutableRefObject<AssistantFileLinkResolverConfig>;
getDirectorySuggestions: GetDirectorySuggestions;
}
const AssistantFileLinkResolverContext =
createContext<AssistantFileLinkResolverContextValue | null>(null);
export function AssistantFileLinkResolverProvider({
client,
serverId,
workspaceRoot,
onOpenWorkspaceFile,
toast,
children,
}: AssistantFileLinkResolverProviderProps) {
const configRef = useRef<AssistantFileLinkResolverConfig>({
client,
serverId,
workspaceRoot,
onOpenWorkspaceFile,
toast,
});
configRef.current = { client, serverId, workspaceRoot, onOpenWorkspaceFile, toast };
const getDirectorySuggestions = useCallback<GetDirectorySuggestions>(async (input) => {
const activeClient = configRef.current.client;
if (!activeClient) {
return { entries: [], error: null };
}
const result = await activeClient.getDirectorySuggestions(input);
return { entries: result.entries, error: result.error };
}, []);
const value = useMemo<AssistantFileLinkResolverContextValue>(
() => ({ configRef, getDirectorySuggestions }),
[getDirectorySuggestions],
);
return (
<AssistantFileLinkResolverContext.Provider value={value}>
{children}
</AssistantFileLinkResolverContext.Provider>
);
}
export function useAssistantFileLinkResolverContext(): AssistantFileLinkResolverContextValue {
const context = useContext(AssistantFileLinkResolverContext);
if (!context) {
throw new Error("AssistantFileLinkResolverProvider is required for assistant file links.");
}
return context;
}
export type { AssistantFileLinkContext };
@@ -1,16 +1,15 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
createAssistantFileLinkResolver, classifyForResolution,
fetchDaemonResolution,
getAssistantFileLinkToken, getAssistantFileLinkToken,
UnresolvedFileLinkError,
type AssistantFileLinkContext, type AssistantFileLinkContext,
type DirectorySuggestionEntry, type DirectorySuggestionEntry,
type DirectorySuggestionResult, type DirectorySuggestionResult,
} from "./resolver"; } from "./resolver";
import type { OpenFileDisposition } from "@/workspace/file-open";
import type { InlinePathTarget } from "./parse";
const CONTEXT: AssistantFileLinkContext = { const CONTEXT: AssistantFileLinkContext = {
serverId: "server-1",
workspaceRoot: "/Users/test/project", workspaceRoot: "/Users/test/project",
}; };
@@ -20,554 +19,201 @@ function resolvedSuggestions(
return { entries, error: null }; return { entries, error: null };
} }
function createDeferred<T>() { function suggestionsFromMap(entriesByQuery: Record<string, DirectorySuggestionEntry[]>): {
let resolve!: (value: T) => void; getDirectorySuggestions: ReturnType<typeof vi.fn>;
let reject!: (error: unknown) => void; searches: Array<{
const promise = new Promise<T>((promiseResolve, promiseReject) => { query: string;
resolve = promiseResolve; cwd: string;
reject = promiseReject; matchMode: "suffix";
}); limit: number;
}>;
return { promise, resolve, reject }; } {
const searches: Array<{
query: string;
cwd: string;
matchMode: "suffix";
limit: number;
}> = [];
const getDirectorySuggestions = vi.fn(
async (input: {
query: string;
cwd: string;
includeFiles: true;
includeDirectories: false;
matchMode: "suffix";
limit: number;
}) => {
searches.push({
query: input.query,
cwd: input.cwd,
matchMode: input.matchMode,
limit: input.limit,
});
return resolvedSuggestions(entriesByQuery[input.query] ?? []);
},
);
return { getDirectorySuggestions, searches };
} }
interface DirectorySearch { describe("classifyForResolution", () => {
query: string; it("returns the directFile target synchronously", () => {
cwd: string; const result = classifyForResolution({ href: "src/components/message.tsx#L33" }, CONTEXT);
includeFiles: true;
includeDirectories: false;
matchMode: "suffix";
limit: number;
}
interface OpenedFile { expect(result).toEqual({
target: InlinePathTarget; kind: "resolved",
disposition: OpenFileDisposition; value: {
} kind: "file",
target: {
class FakeWorkspaceFiles { raw: "src/components/message.tsx#L33",
readonly searches: DirectorySearch[] = []; path: "/Users/test/project/src/components/message.tsx",
readonly openedFiles: OpenedFile[] = []; lineStart: 33,
readonly unresolvedTokens: string[] = []; lineEnd: undefined,
},
constructor(private readonly entriesByQuery: Record<string, DirectorySuggestionEntry[]>) {} },
createResolver() {
return createAssistantFileLinkResolver({
getDirectorySuggestions: this.getDirectorySuggestions,
openWorkspaceFile: this.openWorkspaceFile,
openExternalUrl: async () => {},
onUnresolvedFileCandidate: this.onUnresolvedFileCandidate,
}); });
} });
private getDirectorySuggestions = async ( it("preserves line ranges on direct workspace files", () => {
search: DirectorySearch, const result = classifyForResolution({ href: "src/components/message.tsx:33-40" }, CONTEXT);
): Promise<DirectorySuggestionResult> => {
this.searches.push(search);
return resolvedSuggestions(this.entriesByQuery[search.query] ?? []);
};
private openWorkspaceFile = ( expect(result).toEqual({
target: InlinePathTarget, kind: "resolved",
disposition: OpenFileDisposition, value: {
): void => { kind: "file",
this.openedFiles.push({ target, disposition }); target: {
}; raw: "src/components/message.tsx:33-40",
path: "/Users/test/project/src/components/message.tsx",
lineStart: 33,
lineEnd: 40,
},
},
});
});
private onUnresolvedFileCandidate = (token: string): void => { it("flags basename inline-code as a daemon lookup keyed by suggestion query", () => {
this.unresolvedTokens.push(token); const result = classifyForResolution(
}; { href: "file.ts:12", text: "file.ts:12", sourceType: "inline-code" },
} CONTEXT,
describe("assistant file link resolver", () => {
it("dedupes in-flight prefetches and serves the click from cache", async () => {
const suggestions = vi.fn(async () =>
resolvedSuggestions([{ path: "src/dumm.md", kind: "file" }]),
); );
const openWorkspaceFile = vi.fn();
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl,
});
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
await Promise.all([ expect(result).toEqual({
resolver.prefetch({ context: CONTEXT, source }), kind: "needsLookup",
resolver.prefetch({ context: CONTEXT, source }), ambiguousQuery: "file.ts",
]); token: "file.ts:12",
const result = await resolver.open({ context: CONTEXT, source, disposition: "main" }); target: {
raw: "file.ts:12",
expect(suggestions).toHaveBeenCalledTimes(1); path: "/Users/test/project/file.ts",
expect(suggestions).toHaveBeenCalledWith({ lineStart: 12,
query: "dumm.md",
cwd: "/Users/test/project",
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
limit: 1,
});
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "dumm.md",
path: "/Users/test/project/src/dumm.md",
lineStart: undefined,
lineEnd: undefined, lineEnd: undefined,
}, },
"main", });
});
it("keeps explicit external URLs external", () => {
const result = classifyForResolution({ href: "http://dumm.md", text: "dumm.md" }, CONTEXT);
expect(result).toEqual({
kind: "resolved",
value: { kind: "external", url: "http://dumm.md" },
});
});
it("keeps auto-linkified normal domains external", () => {
const result = classifyForResolution(
{ href: "http://google.com", text: "google.com", markup: "linkify" },
CONTEXT,
); );
expect(openExternalUrl).not.toHaveBeenCalled();
expect(result.opened).toBe(true); expect(result).toEqual({
kind: "resolved",
value: { kind: "external", url: "http://google.com" },
});
}); });
it("click consumes an in-flight hover resolution", async () => { it("returns ignored for non-file-looking content", () => {
const deferred = createDeferred<DirectorySuggestionResult>(); const result = classifyForResolution({ href: "" }, CONTEXT);
const suggestions = vi.fn(() => deferred.promise);
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
const source = { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" };
const prefetch = resolver.prefetch({ context: CONTEXT, source }); expect(result).toEqual({ kind: "resolved", value: { kind: "ignored" } });
const opened = resolver.open({ context: CONTEXT, source, disposition: "main" });
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
await prefetch;
const result = await opened;
expect(suggestions).toHaveBeenCalledTimes(1);
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "dumm.md",
path: "/Users/test/project/docs/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
"main",
);
expect(result.opened).toBe(true);
}); });
});
it("retries a click after hover prefetch fails to query suggestions", async () => { describe("fetchDaemonResolution", () => {
const suggestions = vi it("resolves daemon suggestions into workspace file targets", async () => {
.fn() const { getDirectorySuggestions, searches } = suggestionsFromMap({
.mockRejectedValueOnce(new Error("daemon unavailable"))
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
const openWorkspaceFile = vi.fn();
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl,
});
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
const prefetchResult = await resolver.prefetch({ context: CONTEXT, source });
const openResult = await resolver.open({ context: CONTEXT, source, disposition: "main" });
expect(prefetchResult).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
});
expect(suggestions).toHaveBeenCalledTimes(2);
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "dumm.md",
path: "/Users/test/project/docs/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
"main",
);
expect(openExternalUrl).not.toHaveBeenCalled();
expect(openResult.opened).toBe(true);
});
it("does not cache unresolved candidates", async () => {
const suggestions = vi
.fn()
.mockResolvedValueOnce(resolvedSuggestions([]))
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
const openWorkspaceFile = vi.fn();
const openExternalUrl = vi.fn();
const onUnresolvedFileCandidate = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl,
onUnresolvedFileCandidate,
});
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
const first = await resolver.open({ context: CONTEXT, source, disposition: "main" });
const second = await resolver.open({ context: CONTEXT, source, disposition: "main" });
expect(first).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
opened: false,
});
expect(second.opened).toBe(true);
expect(suggestions).toHaveBeenCalledTimes(2);
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "dumm.md",
path: "/Users/test/project/docs/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
"main",
);
expect(openExternalUrl).not.toHaveBeenCalled();
expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1);
});
it("keys cache entries by server, workspace, and token", async () => {
const suggestions = vi
.fn()
.mockResolvedValueOnce(resolvedSuggestions([{ path: "one/dumm.md", kind: "file" }]))
.mockResolvedValueOnce(resolvedSuggestions([{ path: "two/dumm.md", kind: "file" }]));
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
await resolver.open({ context: CONTEXT, source, disposition: "main" });
await resolver.open({
context: { serverId: "server-1", workspaceRoot: "/Users/test/other" },
source,
disposition: "main",
});
expect(suggestions).toHaveBeenCalledTimes(2);
expect(openWorkspaceFile).toHaveBeenLastCalledWith(
{
raw: "dumm.md",
path: "/Users/test/other/two/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
"main",
);
});
it("does not apply stale async results after the active context changes", async () => {
const deferred = createDeferred<DirectorySuggestionResult>();
let isCurrent = true;
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(() => deferred.promise),
openWorkspaceFile,
openExternalUrl: vi.fn(),
isCurrentContext: () => isCurrent,
});
const opened = resolver.open({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" },
disposition: "main",
});
isCurrent = false;
deferred.resolve(resolvedSuggestions([{ path: "dumm.md", kind: "file" }]));
const result = await opened;
expect(openWorkspaceFile).not.toHaveBeenCalled();
expect(result.opened).toBe(false);
expect(result.kind).toBe("file");
});
it("opens direct workspace file links without querying suggestions", async () => {
const suggestions = vi.fn(async () => resolvedSuggestions([]));
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "src/components/message.tsx#L33" },
disposition: "main",
});
expect(suggestions).not.toHaveBeenCalled();
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "src/components/message.tsx#L33",
path: "/Users/test/project/src/components/message.tsx",
lineStart: 33,
lineEnd: undefined,
},
"main",
);
expect(result.opened).toBe(true);
});
it("preserves direct workspace file line ranges", async () => {
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
await resolver.open({
context: CONTEXT,
source: { href: "src/components/message.tsx:33-40" },
disposition: "main",
});
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "src/components/message.tsx:33-40",
path: "/Users/test/project/src/components/message.tsx",
lineStart: 33,
lineEnd: 40,
},
"main",
);
});
it("opens basename line refs when the daemon returns that exact filename", async () => {
const workspaceFiles = new FakeWorkspaceFiles({
"file.ts": [{ path: "packages/app/src/file.ts", kind: "file" }], "file.ts": [{ path: "packages/app/src/file.ts", kind: "file" }],
}); });
const resolver = workspaceFiles.createResolver();
const result = await resolver.open({ const result = await fetchDaemonResolution({
context: { ...CONTEXT, workspaceRoot: "/Users/test/project" }, ambiguousQuery: "file.ts",
source: { token: "file.ts:12",
href: "file.ts:12", target: {
text: "file.ts:12", raw: "file.ts:12",
sourceType: "inline-code", path: "/Users/test/project/file.ts",
lineStart: 12,
lineEnd: undefined,
}, },
disposition: "main", workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
}); });
expect(workspaceFiles.searches).toEqual([ expect(searches).toEqual([
{ {
query: "file.ts", query: "file.ts",
cwd: "/Users/test/project", cwd: "/Users/test/project",
includeFiles: true,
includeDirectories: false,
matchMode: "suffix", matchMode: "suffix",
limit: 1, limit: 1,
}, },
]); ]);
expect(workspaceFiles.openedFiles).toEqual([ expect(result).toEqual({
{ raw: "file.ts:12",
path: "/Users/test/project/packages/app/src/file.ts",
lineStart: 12,
lineEnd: undefined,
});
});
it("throws a typed unresolved error when the daemon finds no match", async () => {
const { getDirectorySuggestions } = suggestionsFromMap({});
await expect(
fetchDaemonResolution({
ambiguousQuery: "src/file.ts",
token: "src/file.ts",
target: { target: {
raw: "file.ts:12", raw: "src/file.ts",
path: "/Users/test/project/packages/app/src/file.ts", path: "/Users/test/project/src/file.ts",
lineStart: 12, lineStart: undefined,
lineEnd: undefined, lineEnd: undefined,
}, },
disposition: "main", workspaceRoot: "/Users/test/project",
}, getDirectorySuggestions,
]);
expect(result.opened).toBe(true);
});
it("reports inline-code subpaths as unresolved when suffix suggestions find no file", async () => {
const workspaceFiles = new FakeWorkspaceFiles({});
const resolver = workspaceFiles.createResolver();
const result = await resolver.open({
context: { ...CONTEXT, workspaceRoot: "/Users/test/project" },
source: {
href: "src/file.ts",
text: "src/file.ts",
sourceType: "inline-code",
},
disposition: "main",
});
expect(workspaceFiles.searches).toEqual([
{
query: "src/file.ts",
cwd: "/Users/test/project",
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
limit: 1,
},
]);
expect(workspaceFiles.openedFiles).toEqual([]);
expect(workspaceFiles.unresolvedTokens).toEqual(["src/file.ts"]);
expect(result).toEqual({
kind: "unresolvedFileCandidate",
token: "src/file.ts",
opened: false,
});
});
it("passes side open disposition to workspace file links", async () => {
const openWorkspaceFile = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
openWorkspaceFile,
openExternalUrl: vi.fn(),
});
await resolver.open({
context: CONTEXT,
source: { href: "src/components/message.tsx#L33" },
disposition: "side",
});
expect(openWorkspaceFile).toHaveBeenCalledWith(
{
raw: "src/components/message.tsx#L33",
path: "/Users/test/project/src/components/message.tsx",
lineStart: 33,
lineEnd: undefined,
},
"side",
);
});
it("keeps explicit external URLs external", async () => {
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
openWorkspaceFile: vi.fn(),
openExternalUrl,
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md" },
disposition: "main",
});
expect(openExternalUrl).toHaveBeenCalledWith("http://dumm.md");
expect(result).toEqual({
kind: "external",
url: "http://dumm.md",
opened: true,
});
});
it("keeps auto-linkified normal domains external", async () => {
const suggestions = vi.fn(async () => resolvedSuggestions([]));
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile: vi.fn(),
openExternalUrl,
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://google.com", text: "google.com", markup: "linkify" },
disposition: "main",
});
expect(suggestions).not.toHaveBeenCalled();
expect(openExternalUrl).toHaveBeenCalledWith("http://google.com");
expect(result).toEqual({
kind: "external",
url: "http://google.com",
opened: true,
});
});
it("keeps auto-linkified normal domain paths external", async () => {
const suggestions = vi.fn(async () => resolvedSuggestions([]));
const openExternalUrl = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: suggestions,
openWorkspaceFile: vi.fn(),
openExternalUrl,
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://openai.com/path", text: "openai.com/path", sourceInfo: "auto" },
disposition: "main",
});
expect(suggestions).not.toHaveBeenCalled();
expect(openExternalUrl).toHaveBeenCalledWith("http://openai.com/path");
expect(result).toEqual({
kind: "external",
url: "http://openai.com/path",
opened: true,
});
});
it("does not open unresolved linkified markdown filenames in the browser", async () => {
const openWorkspaceFile = vi.fn();
const openExternalUrl = vi.fn();
const onUnresolvedFileCandidate = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])),
openWorkspaceFile,
openExternalUrl,
onUnresolvedFileCandidate,
});
const prefetchResult = await resolver.prefetch({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" },
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" },
disposition: "main",
});
expect(openWorkspaceFile).not.toHaveBeenCalled();
expect(openExternalUrl).not.toHaveBeenCalled();
expect(prefetchResult).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
});
expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1);
expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md");
expect(result).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
opened: false,
});
});
it("keeps failed ambiguous resolution out of the browser", async () => {
const openExternalUrl = vi.fn();
const onUnresolvedFileCandidate = vi.fn();
const resolver = createAssistantFileLinkResolver({
getDirectorySuggestions: vi.fn(async () => {
throw new Error("daemon unavailable");
}), }),
openWorkspaceFile: vi.fn(), ).rejects.toEqual(new UnresolvedFileLinkError("src/file.ts"));
openExternalUrl,
onUnresolvedFileCandidate,
});
const result = await resolver.open({
context: CONTEXT,
source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" },
disposition: "main",
});
expect(openExternalUrl).not.toHaveBeenCalled();
expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md");
expect(result).toEqual({
kind: "unresolvedFileCandidate",
token: "dumm.md",
opened: false,
});
}); });
it("throws a typed unresolved error when the daemon throws", async () => {
const getDirectorySuggestions = vi.fn(async () => {
throw new Error("daemon unavailable");
});
await expect(
fetchDaemonResolution({
ambiguousQuery: "dumm.md",
token: "dumm.md",
target: {
raw: "dumm.md",
path: "/Users/test/project/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
}),
).rejects.toEqual(new UnresolvedFileLinkError("dumm.md"));
});
});
describe("getAssistantFileLinkToken", () => {
it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => { it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => {
expect( expect(
getAssistantFileLinkToken({ getAssistantFileLinkToken({
+107 -198
View File
@@ -4,12 +4,6 @@ import {
type AssistantFileLinkClassification, type AssistantFileLinkClassification,
type InlinePathTarget, type InlinePathTarget,
} from "./parse"; } from "./parse";
import type { OpenFileDisposition } from "@/workspace/file-open";
export interface AssistantFileLinkContext {
serverId?: string;
workspaceRoot?: string;
}
export interface AssistantFileLinkSource { export interface AssistantFileLinkSource {
href: string; href: string;
@@ -19,6 +13,10 @@ export interface AssistantFileLinkSource {
sourceType?: "inline-code"; sourceType?: "inline-code";
} }
export interface AssistantFileLinkContext {
workspaceRoot?: string;
}
export interface DirectorySuggestionEntry { export interface DirectorySuggestionEntry {
path: string; path: string;
kind: "file" | "directory"; kind: "file" | "directory";
@@ -29,152 +27,121 @@ export interface DirectorySuggestionResult {
error: string | null; error: string | null;
} }
export interface AssistantFileLinkResolverDependencies { export type GetDirectorySuggestions = (input: {
getDirectorySuggestions: (input: { query: string;
query: string; cwd: string;
cwd: string; includeFiles: true;
includeFiles: true; includeDirectories: false;
includeDirectories: false; matchMode: "suffix";
matchMode: "suffix"; limit: number;
limit: number; }) => Promise<DirectorySuggestionResult>;
}) => Promise<DirectorySuggestionResult>;
openWorkspaceFile: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
openExternalUrl: (url: string) => void | Promise<void>;
onUnresolvedFileCandidate?: (token: string) => void;
isCurrentContext?: (context: AssistantFileLinkContext) => boolean;
}
export interface AssistantFileLinkResolver {
prefetch(input: AssistantFileLinkPrefetchInput): Promise<ResolvedAssistantFileLink>;
open(input: AssistantFileLinkOpenInput): Promise<AssistantFileLinkOpenResult>;
}
export interface AssistantFileLinkPrefetchInput {
context: AssistantFileLinkContext;
source: AssistantFileLinkSource;
}
export interface AssistantFileLinkOpenInput extends AssistantFileLinkPrefetchInput {
disposition: OpenFileDisposition;
}
export type ResolvedAssistantFileLink = export type ResolvedAssistantFileLink =
| { kind: "external"; url: string }
| { kind: "file"; target: InlinePathTarget }
| { kind: "ignored" };
export type AssistantFileLinkResolution =
| { kind: "resolved"; value: ResolvedAssistantFileLink }
| { | {
kind: "external"; kind: "needsLookup";
url: string; ambiguousQuery: string;
}
| {
kind: "file";
target: InlinePathTarget;
}
| {
kind: "unresolvedFileCandidate";
token: string; token: string;
} target: InlinePathTarget;
| {
kind: "ignored";
}; };
export type AssistantFileLinkOpenResult = ResolvedAssistantFileLink & { export interface FetchDaemonResolutionInput {
opened: boolean; ambiguousQuery: string;
};
type CachedAssistantFileLink = Exclude<ResolvedAssistantFileLink, { kind: "external" }>;
interface ParsedAssistantFileLinkInteraction {
token: string; token: string;
classification: AssistantFileLinkClassification; target: InlinePathTarget;
workspaceRoot?: string;
getDirectorySuggestions: GetDirectorySuggestions;
} }
export function createAssistantFileLinkResolver( export class UnresolvedFileLinkError extends Error {
dependencies: AssistantFileLinkResolverDependencies, constructor(readonly token: string) {
): AssistantFileLinkResolver { super(`No file found for ${token}`);
const cache = new Map<string, CachedAssistantFileLink>(); this.name = "UnresolvedFileLinkError";
const inFlight = new Map<string, Promise<CachedAssistantFileLink>>(); }
}
async function resolve( export async function fetchDaemonResolution({
input: AssistantFileLinkPrefetchInput, ambiguousQuery,
): Promise<ResolvedAssistantFileLink> { token,
const parsed = parseInteraction(input); target,
if (!parsed) { workspaceRoot,
return { kind: "ignored" }; getDirectorySuggestions,
} }: FetchDaemonResolutionInput): Promise<InlinePathTarget> {
const trimmedRoot = workspaceRoot?.trim();
if (!trimmedRoot) {
throw new UnresolvedFileLinkError(token);
}
if (parsed.classification.kind === "external") { let suggestions: DirectorySuggestionResult;
return { kind: "external", url: parsed.classification.raw }; try {
} suggestions = await getDirectorySuggestions({
query: ambiguousQuery,
cwd: trimmedRoot,
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
limit: 1,
});
} catch {
throw new UnresolvedFileLinkError(token);
}
if ( const match = suggestions.entries.find((entry) => entry.kind === "file");
parsed.classification.kind === "directFile" && if (!match || suggestions.error) {
!shouldResolveDirectFileThroughSuggestions({ throw new UnresolvedFileLinkError(token);
context: input.context,
source: input.source,
token: parsed.token,
target: parsed.classification.target,
})
) {
return { kind: "file", target: parsed.classification.target };
}
const key = getResolutionKey(input.context, parsed.token);
const cached = cache.get(key);
if (cached) {
return cached;
}
const active = inFlight.get(key);
if (active) {
return active;
}
const request = resolveAmbiguousCandidate({
context: input.context,
token: parsed.token,
target: parsed.classification.target,
getDirectorySuggestions: dependencies.getDirectorySuggestions,
})
.then((result) => {
if (result.kind === "file") {
cache.set(key, result);
}
inFlight.delete(key);
return result;
})
.catch((): CachedAssistantFileLink => {
inFlight.delete(key);
return { kind: "unresolvedFileCandidate", token: parsed.token };
});
inFlight.set(key, request);
return request;
} }
return { return {
prefetch(input) { ...target,
return resolve(input); path: joinWorkspacePath(trimmedRoot, match.path),
}, };
async open(input) { }
const resolved = await resolve(input);
if (!canApplyResult(input.context, dependencies.isCurrentContext)) {
return { ...resolved, opened: false };
}
if (resolved.kind === "file") { export function classifyForResolution(
dependencies.openWorkspaceFile(resolved.target, input.disposition); source: AssistantFileLinkSource,
return { ...resolved, opened: true }; context: AssistantFileLinkContext,
} ): AssistantFileLinkResolution {
const token = getAssistantFileLinkToken(source).trim();
if (!token) {
return { kind: "resolved", value: { kind: "ignored" } };
}
if (resolved.kind === "external") { const classification = classifyAssistantFileLink(token, {
await dependencies.openExternalUrl(resolved.url); workspaceRoot: context.workspaceRoot,
return { ...resolved, opened: true }; });
} if (!classification) {
return { kind: "resolved", value: { kind: "ignored" } };
}
if (classification.kind === "external") {
return { kind: "resolved", value: { kind: "external", url: classification.raw } };
}
if (
classification.kind === "directFile" &&
!shouldResolveDirectFileThroughSuggestions({
context,
source,
token,
target: classification.target,
})
) {
return { kind: "resolved", value: { kind: "file", target: classification.target } };
}
if (resolved.kind === "unresolvedFileCandidate") { const workspaceRoot = context.workspaceRoot?.trim();
dependencies.onUnresolvedFileCandidate?.(resolved.token); if (!workspaceRoot) {
} return { kind: "resolved", value: { kind: "ignored" } };
}
return { ...resolved, opened: false }; return {
}, kind: "needsLookup",
ambiguousQuery: getAmbiguousSuggestionQuery(classification.target, workspaceRoot),
token,
target: classification.target,
}; };
} }
@@ -189,59 +156,10 @@ export function getAssistantFileLinkToken(source: AssistantFileLinkSource): stri
return source.href; return source.href;
} }
function parseInteraction( export function getAmbiguousSuggestionQuery(
input: AssistantFileLinkPrefetchInput, target: InlinePathTarget,
): ParsedAssistantFileLinkInteraction | null { workspaceRoot: string,
const token = getAssistantFileLinkToken(input.source).trim(); ): string {
if (!token) {
return null;
}
const classification = classifyAssistantFileLink(token, {
workspaceRoot: input.context.workspaceRoot,
});
if (!classification) {
return null;
}
return { token, classification };
}
async function resolveAmbiguousCandidate(input: {
context: AssistantFileLinkContext;
token: string;
target: InlinePathTarget;
getDirectorySuggestions: AssistantFileLinkResolverDependencies["getDirectorySuggestions"];
}): Promise<CachedAssistantFileLink> {
const workspaceRoot = input.context.workspaceRoot?.trim();
if (!workspaceRoot) {
return { kind: "unresolvedFileCandidate", token: input.token };
}
const query = getAmbiguousSuggestionQuery(input.target, workspaceRoot);
const suggestions = await input.getDirectorySuggestions({
query,
cwd: workspaceRoot,
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
limit: 1,
});
const match = suggestions.entries.find((entry) => entry.kind === "file");
if (!match || suggestions.error) {
return { kind: "unresolvedFileCandidate", token: input.token };
}
return {
kind: "file",
target: {
...input.target,
path: joinWorkspacePath(workspaceRoot, match.path),
},
};
}
function getAmbiguousSuggestionQuery(target: InlinePathTarget, workspaceRoot: string): string {
const normalizedRoot = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, ""); const normalizedRoot = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
const normalizedPath = target.path.replace(/\\/g, "/"); const normalizedPath = target.path.replace(/\\/g, "/");
const prefix = `${normalizedRoot}/`; const prefix = `${normalizedRoot}/`;
@@ -253,7 +171,7 @@ function getAmbiguousSuggestionQuery(target: InlinePathTarget, workspaceRoot: st
return lastSlash >= 0 ? normalizedPath.slice(lastSlash + 1) : normalizedPath; return lastSlash >= 0 ? normalizedPath.slice(lastSlash + 1) : normalizedPath;
} }
function shouldResolveDirectFileThroughSuggestions(input: { export function shouldResolveDirectFileThroughSuggestions(input: {
context: AssistantFileLinkContext; context: AssistantFileLinkContext;
source: AssistantFileLinkSource; source: AssistantFileLinkSource;
token: string; token: string;
@@ -285,23 +203,14 @@ function isAbsoluteInlineCodeToken(token: string): boolean {
); );
} }
function getResolutionKey(context: AssistantFileLinkContext, token: string): string {
return [context.serverId ?? "", context.workspaceRoot ?? "", token].join("\0");
}
function isLinkifiedSource(source: AssistantFileLinkSource): boolean { function isLinkifiedSource(source: AssistantFileLinkSource): boolean {
return source.markup === "linkify" || source.sourceInfo === "auto"; return source.markup === "linkify" || source.sourceInfo === "auto";
} }
function canApplyResult(
context: AssistantFileLinkContext,
isCurrentContext: AssistantFileLinkResolverDependencies["isCurrentContext"],
): boolean {
return isCurrentContext ? isCurrentContext(context) : true;
}
function joinWorkspacePath(workspaceRoot: string, relativePath: string): string { function joinWorkspacePath(workspaceRoot: string, relativePath: string): string {
const root = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, ""); const root = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
const child = relativePath.replace(/\\/g, "/").replace(/^\/+/, ""); const child = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
return root ? `${root}/${child}` : child; return root ? `${root}/${child}` : child;
} }
export type { AssistantFileLinkClassification };
@@ -0,0 +1,328 @@
/**
* @vitest-environment jsdom
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import React, { useCallback, useMemo, useState, type ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import type { InlinePathTarget } from "./parse";
import { AssistantFileLinkResolverProvider } from "./provider";
import type { DirectorySuggestionResult } from "./resolver";
import { useFileLink } from "./use-file-link";
import type { OpenFileDisposition } from "@/workspace/file-open";
vi.mock("@/utils/open-external-url", () => ({
openExternalUrl: vi.fn(async () => {}),
}));
const SOURCE = {
href: "http://dumm.md",
text: "dumm.md",
markup: "linkify",
};
function resolvedSuggestions(
entries: DirectorySuggestionResult["entries"],
): DirectorySuggestionResult {
return { entries, error: null };
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
interface OpenedFile {
target: InlinePathTarget;
disposition: OpenFileDisposition;
}
interface TestClient {
getDirectorySuggestions: (input: {
query: string;
cwd: string;
includeFiles: true;
includeDirectories: false;
matchMode: "suffix";
limit: number;
}) => Promise<DirectorySuggestionResult>;
}
function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
}
function createWrapper(input: {
client: TestClient;
openedFiles: OpenedFile[];
toast?: {
show: ReturnType<typeof vi.fn>;
copied: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
};
}) {
const queryClient = createQueryClient();
return function Wrapper({ children }: { children: ReactNode }) {
const openWorkspaceFile = useCallback(
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
input.openedFiles.push({ target, disposition });
},
[],
);
return (
<QueryClientProvider client={queryClient}>
<AssistantFileLinkResolverProvider
client={input.client}
serverId="server-1"
workspaceRoot="/Users/test/project"
onOpenWorkspaceFile={openWorkspaceFile}
toast={input.toast}
>
{children}
</AssistantFileLinkResolverProvider>
</QueryClientProvider>
);
};
}
describe("useFileLink", () => {
it("returns the same object across no-op parent rerenders", () => {
const getDirectorySuggestions = vi.fn(async () => resolvedSuggestions([]));
const queryClient = createQueryClient();
const Provider = AssistantFileLinkResolverProvider as React.ComponentType<
Omit<React.ComponentProps<typeof AssistantFileLinkResolverProvider>, "children"> & {
children?: ReactNode;
}
>;
function ChurningProviderWrapper({ children }: { children: ReactNode }) {
return React.createElement(
QueryClientProvider,
{ client: queryClient },
React.createElement(
Provider,
{
client: { getDirectorySuggestions },
serverId: "server-1",
workspaceRoot: "/Users/test/project",
onOpenWorkspaceFile: () => {},
toast: { show: vi.fn(), copied: vi.fn(), error: vi.fn() },
},
children,
),
);
}
const { result, rerender } = renderHook(() => useFileLink({ ...SOURCE }), {
wrapper: ChurningProviderWrapper,
});
const first = result.current;
rerender();
expect(result.current).toBe(first);
expect(result.current.onHoverIn).toBe(first.onHoverIn);
expect(result.current.onPress).toBe(first.onPress);
expect(result.current.onAuxPress).toBe(first.onAuxPress);
expect(result.current.open).toBe(first.open);
});
it("does not cache unresolved lookups forever", async () => {
const getDirectorySuggestions = vi
.fn()
.mockResolvedValueOnce(resolvedSuggestions([]))
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
const openedFiles: OpenedFile[] = [];
const toast = { show: vi.fn(), copied: vi.fn(), error: vi.fn() };
const { result } = renderHook(() => useFileLink(SOURCE), {
wrapper: createWrapper({
client: { getDirectorySuggestions },
openedFiles,
toast,
}),
});
act(() => {
result.current.onPress();
});
await waitFor(() => {
expect(toast.show).toHaveBeenCalledWith("No file found for dumm.md", {
variant: "error",
testID: "assistant-file-link-not-found-toast",
});
});
act(() => {
result.current.onPress();
});
await waitFor(() => {
expect(openedFiles).toEqual([
{
target: {
raw: "dumm.md",
path: "/Users/test/project/docs/dumm.md",
lineStart: undefined,
lineEnd: undefined,
},
disposition: "main",
},
]);
});
expect(getDirectorySuggestions).toHaveBeenCalledTimes(2);
});
it("click retries after hover prefetch fails", async () => {
const getDirectorySuggestions = vi
.fn()
.mockRejectedValueOnce(new Error("daemon unavailable"))
.mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
const openedFiles: OpenedFile[] = [];
const { result } = renderHook(() => useFileLink(SOURCE), {
wrapper: createWrapper({
client: { getDirectorySuggestions },
openedFiles,
}),
});
act(() => {
result.current.onHoverIn();
});
await waitFor(() => {
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
});
act(() => {
result.current.onPress();
});
await waitFor(() => {
expect(openedFiles).toHaveLength(1);
});
expect(getDirectorySuggestions).toHaveBeenCalledTimes(2);
});
it("dedupes two links pointing at the same source", async () => {
const deferred = createDeferred<DirectorySuggestionResult>();
const getDirectorySuggestions = vi.fn(() => deferred.promise);
const openedFiles: OpenedFile[] = [];
const { result } = renderHook(
() => ({
first: useFileLink(SOURCE),
second: useFileLink(SOURCE),
}),
{
wrapper: createWrapper({
client: { getDirectorySuggestions },
openedFiles,
}),
},
);
act(() => {
result.current.first.onHoverIn();
result.current.second.onHoverIn();
});
await waitFor(() => {
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
});
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
await waitFor(() => {
expect(result.current.first.target?.path).toBe("/Users/test/project/docs/dumm.md");
expect(result.current.second.target?.path).toBe("/Users/test/project/docs/dumm.md");
});
});
it("hover then click uses the prefetched result", async () => {
const getDirectorySuggestions = vi.fn(async () =>
resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]),
);
const openedFiles: OpenedFile[] = [];
const { result } = renderHook(() => useFileLink(SOURCE), {
wrapper: createWrapper({
client: { getDirectorySuggestions },
openedFiles,
}),
});
act(() => {
result.current.onHoverIn();
});
await waitFor(() => {
expect(result.current.target?.path).toBe("/Users/test/project/docs/dumm.md");
});
act(() => {
result.current.onPress();
});
await waitFor(() => {
expect(openedFiles).toHaveLength(1);
});
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
});
it("does not open a stale result after the workspace changes", async () => {
const deferred = createDeferred<DirectorySuggestionResult>();
const getDirectorySuggestions = vi.fn(() => deferred.promise);
const openedFiles: OpenedFile[] = [];
const queryClient = createQueryClient();
function Wrapper({ children }: { children: ReactNode }) {
const [workspaceRoot, setWorkspaceRoot] = useState("/Users/test/project");
const client = useMemo(() => ({ getDirectorySuggestions }), []);
const openWorkspaceFile = useCallback(
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
openedFiles.push({ target, disposition });
},
[],
);
return (
<QueryClientProvider client={queryClient}>
<AssistantFileLinkResolverProvider
client={client}
serverId="server-1"
workspaceRoot={workspaceRoot}
onOpenWorkspaceFile={openWorkspaceFile}
>
<WorkspaceSwitchContext.Provider value={setWorkspaceRoot}>
{children}
</WorkspaceSwitchContext.Provider>
</AssistantFileLinkResolverProvider>
</QueryClientProvider>
);
}
const { result } = renderHook(
() => ({
link: useFileLink(SOURCE),
setWorkspaceRoot: React.useContext(WorkspaceSwitchContext),
}),
{ wrapper: Wrapper },
);
act(() => {
result.current.link.onPress();
});
act(() => {
result.current.setWorkspaceRoot("/Users/test/other");
});
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
await waitFor(() => {
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
});
expect(openedFiles).toEqual([]);
});
});
const WorkspaceSwitchContext = React.createContext<(workspaceRoot: string) => void>(() => {});
@@ -0,0 +1,347 @@
import { useCallback, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useStableEvent } from "@/hooks/use-stable-event";
import type { OpenFileDisposition } from "@/workspace/file-open";
import { openExternalUrl } from "@/utils/open-external-url";
import type { InlinePathTarget } from "./parse";
import {
useAssistantFileLinkResolverContext,
type AssistantFileLinkResolverContextValue,
} from "./provider";
import {
classifyForResolution,
fetchDaemonResolution,
UnresolvedFileLinkError,
type AssistantFileLinkResolution,
type AssistantFileLinkSource,
} from "./resolver";
export interface UseFileLinkResult {
target: InlinePathTarget | null;
onHoverIn: () => void;
onPress: () => void;
onAuxPress: () => void;
open: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
}
export interface AssistantFileLinkActions {
open(source: AssistantFileLinkSource, disposition: OpenFileDisposition): void;
canOpen(source: AssistantFileLinkSource): boolean;
canResolveFile(source: AssistantFileLinkSource): boolean;
}
type AssistantFileLinkQueryKey = readonly [
"assistantFileLink",
string | null,
string | null,
string,
];
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, ""] as const;
export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult {
const context = useAssistantFileLinkResolverContext();
const queryClient = useQueryClient();
const stableSource = useStableSource(source);
const activeConfig = context.configRef.current;
const workspaceRoot = activeConfig.workspaceRoot;
const serverId = activeConfig.serverId;
const resolution = useMemo(
() =>
classifyForResolution(stableSource, {
workspaceRoot,
}),
[stableSource, workspaceRoot],
);
const queryKey = useMemo(
() =>
resolution.kind === "needsLookup"
? assistantFileLinkQueryKey({
serverId,
workspaceRoot,
ambiguousQuery: resolution.ambiguousQuery,
})
: DISABLED_QUERY_KEY,
[resolution, serverId, workspaceRoot],
);
const query = useQuery({
queryKey,
queryFn: () => {
if (resolution.kind !== "needsLookup") {
throw new Error("Assistant file link lookup requested for a sync link.");
}
return fetchDaemonResolution({
ambiguousQuery: resolution.ambiguousQuery,
token: resolution.token,
target: resolution.target,
workspaceRoot,
getDirectorySuggestions: context.getDirectorySuggestions,
});
},
enabled: false,
retry: 0,
staleTime: Infinity,
});
const open = useStableEvent(
(nextSource: AssistantFileLinkSource, disposition: OpenFileDisposition) => {
openAssistantFileLink({
source: nextSource,
disposition,
context,
queryClient,
});
},
);
const onHoverIn = useStableEvent(() => {
if (resolution.kind !== "needsLookup") {
return;
}
void queryClient.prefetchQuery({
queryKey,
queryFn: () =>
fetchDaemonResolution({
ambiguousQuery: resolution.ambiguousQuery,
token: resolution.token,
target: resolution.target,
workspaceRoot,
getDirectorySuggestions: context.getDirectorySuggestions,
}),
retry: 0,
staleTime: Infinity,
});
});
const onPress = useStableEvent(() => {
open(stableSource, "main");
});
const onAuxPress = useStableEvent(() => {
open(stableSource, "side");
});
const target = useMemo(() => {
if (resolution.kind === "resolved") {
return resolution.value.kind === "file" ? resolution.value.target : null;
}
return query.data ?? null;
}, [query.data, resolution]);
return useMemo(
() => ({ target, onHoverIn, onPress, onAuxPress, open }),
[target, onHoverIn, onPress, onAuxPress, open],
);
}
export function useAssistantFileLinkActions(): AssistantFileLinkActions {
const context = useAssistantFileLinkResolverContext();
const actionLink = useFileLink(ACTION_LINK_SOURCE);
const open = useStableEvent(
(source: AssistantFileLinkSource, disposition: OpenFileDisposition) => {
actionLink.open(source, disposition);
},
);
const canOpen = useCallback(
(source: AssistantFileLinkSource) =>
canOpenAssistantFileLink(source, context.configRef.current.workspaceRoot),
[context.configRef],
);
const canResolveFile = useCallback(
(source: AssistantFileLinkSource) =>
canResolveAssistantFileLinkToFile(source, context.configRef.current.workspaceRoot),
[context.configRef],
);
return useMemo(() => ({ open, canOpen, canResolveFile }), [open, canOpen, canResolveFile]);
}
function openAssistantFileLink(input: {
source: AssistantFileLinkSource;
disposition: OpenFileDisposition;
context: AssistantFileLinkResolverContextValue;
queryClient: ReturnType<typeof useQueryClient>;
}): void {
const capturedConfig = input.context.configRef.current;
const capturedResolution = classifyForResolution(input.source, {
workspaceRoot: capturedConfig.workspaceRoot,
});
if (capturedResolution.kind === "resolved") {
void dispatchResolvedLink({
resolution: capturedResolution,
disposition: input.disposition,
capturedServerId: capturedConfig.serverId,
capturedWorkspaceRoot: capturedConfig.workspaceRoot,
context: input.context,
});
return;
}
const capturedQueryKey = assistantFileLinkQueryKey({
serverId: capturedConfig.serverId,
workspaceRoot: capturedConfig.workspaceRoot,
ambiguousQuery: capturedResolution.ambiguousQuery,
});
const run = async () => {
try {
const target = await input.queryClient.fetchQuery({
queryKey: capturedQueryKey,
queryFn: () =>
fetchDaemonResolution({
ambiguousQuery: capturedResolution.ambiguousQuery,
token: capturedResolution.token,
target: capturedResolution.target,
workspaceRoot: capturedConfig.workspaceRoot,
getDirectorySuggestions: input.context.getDirectorySuggestions,
}),
retry: 0,
staleTime: Infinity,
});
await dispatchFileTarget({
target,
disposition: input.disposition,
capturedServerId: capturedConfig.serverId,
capturedWorkspaceRoot: capturedConfig.workspaceRoot,
context: input.context,
});
} catch (error) {
await dispatchUnresolvedError({
error,
fallbackToken: capturedResolution.token,
capturedServerId: capturedConfig.serverId,
capturedWorkspaceRoot: capturedConfig.workspaceRoot,
context: input.context,
});
}
};
void run();
}
function canOpenAssistantFileLink(
source: AssistantFileLinkSource,
workspaceRoot: string | undefined,
): boolean {
const resolution = classifyForResolution(source, { workspaceRoot });
return resolution.kind === "needsLookup" || resolution.value.kind !== "ignored";
}
function canResolveAssistantFileLinkToFile(
source: AssistantFileLinkSource,
workspaceRoot: string | undefined,
): boolean {
const resolution = classifyForResolution(source, { workspaceRoot });
return resolution.kind === "needsLookup" || resolution.value.kind === "file";
}
function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSource {
const { href, text, markup, sourceInfo, sourceType } = source;
return useMemo(
() => ({ href, text, markup, sourceInfo, sourceType }),
[href, text, markup, sourceInfo, sourceType],
);
}
function assistantFileLinkQueryKey(input: {
serverId?: string;
workspaceRoot?: string;
ambiguousQuery: string;
}): AssistantFileLinkQueryKey {
return [
"assistantFileLink",
input.serverId ?? null,
input.workspaceRoot ?? null,
input.ambiguousQuery,
];
}
async function dispatchResolvedLink(input: {
resolution: Extract<AssistantFileLinkResolution, { kind: "resolved" }>;
disposition: OpenFileDisposition;
capturedServerId?: string;
capturedWorkspaceRoot?: string;
context: AssistantFileLinkResolverContextValue;
}) {
const { value } = input.resolution;
if (value.kind === "file") {
await dispatchFileTarget({
target: value.target,
disposition: input.disposition,
capturedServerId: input.capturedServerId,
capturedWorkspaceRoot: input.capturedWorkspaceRoot,
context: input.context,
});
return;
}
if (value.kind === "external") {
await dispatchExternalUrl({
url: value.url,
capturedServerId: input.capturedServerId,
capturedWorkspaceRoot: input.capturedWorkspaceRoot,
context: input.context,
});
}
}
async function dispatchFileTarget(input: {
target: InlinePathTarget;
disposition: OpenFileDisposition;
capturedServerId?: string;
capturedWorkspaceRoot?: string;
context: AssistantFileLinkResolverContextValue;
}) {
const current = input.context.configRef.current;
if (
current.serverId !== input.capturedServerId ||
current.workspaceRoot !== input.capturedWorkspaceRoot
) {
return;
}
current.onOpenWorkspaceFile?.(input.target, input.disposition);
}
async function dispatchExternalUrl(input: {
url: string;
capturedServerId?: string;
capturedWorkspaceRoot?: string;
context: AssistantFileLinkResolverContextValue;
}) {
const current = input.context.configRef.current;
if (
current.serverId !== input.capturedServerId ||
current.workspaceRoot !== input.capturedWorkspaceRoot
) {
return;
}
await openExternalUrl(input.url);
}
async function dispatchUnresolvedError(input: {
error: unknown;
fallbackToken: string;
capturedServerId?: string;
capturedWorkspaceRoot?: string;
context: AssistantFileLinkResolverContextValue;
}) {
const current = input.context.configRef.current;
if (
current.serverId !== input.capturedServerId ||
current.workspaceRoot !== input.capturedWorkspaceRoot
) {
return;
}
const token =
input.error instanceof UnresolvedFileLinkError ? input.error.token : input.fallbackToken;
current.toast?.show(`No file found for ${token}`, {
variant: "error",
testID: "assistant-file-link-not-found-toast",
});
}
const ACTION_LINK_SOURCE: AssistantFileLinkSource = {
href: "",
};
@@ -1,90 +0,0 @@
import { useMemo, useRef } from "react";
import type { DaemonClient } from "@server/client/daemon-client";
import type { ToastApi } from "@/components/toast-host";
import {
createAssistantFileLinkResolver,
type AssistantFileLinkContext,
type AssistantFileLinkOpenInput,
type AssistantFileLinkPrefetchInput,
} from "./resolver";
import type { InlinePathTarget } from "./parse";
import type { OpenFileDisposition } from "@/workspace/file-open";
import { openExternalUrl } from "@/utils/open-external-url";
export interface UseAssistantFileLinkResolverOptions {
client?: DaemonClient | null;
serverId?: string;
workspaceRoot?: string;
onOpenWorkspaceFile?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
toast?: ToastApi | null;
}
export interface AssistantFileLinkActions {
prefetch(input: Omit<AssistantFileLinkPrefetchInput, "context">): void;
open(input: Omit<AssistantFileLinkOpenInput, "context">): void;
}
export function useAssistantFileLinkResolver({
client,
serverId,
workspaceRoot,
onOpenWorkspaceFile,
toast,
}: UseAssistantFileLinkResolverOptions): AssistantFileLinkActions {
const context: AssistantFileLinkContext = useMemo(
() => ({
serverId,
workspaceRoot,
}),
[serverId, workspaceRoot],
);
const latestContextRef = useRef(context);
latestContextRef.current = context;
const resolver = useMemo(
() =>
createAssistantFileLinkResolver({
async getDirectorySuggestions(input) {
if (!client) {
return { entries: [], error: null };
}
const result = await client.getDirectorySuggestions(input);
return {
entries: result.entries,
error: result.error,
};
},
openWorkspaceFile(target, disposition) {
onOpenWorkspaceFile?.(target, disposition);
},
openExternalUrl,
onUnresolvedFileCandidate(token) {
toast?.show(`No file found for ${token}`, {
variant: "error",
testID: "assistant-file-link-not-found-toast",
});
},
isCurrentContext(candidate) {
const current = latestContextRef.current;
return (
current.serverId === candidate.serverId &&
current.workspaceRoot === candidate.workspaceRoot
);
},
}),
[client, onOpenWorkspaceFile, toast],
);
return useMemo(
() => ({
prefetch(input) {
void resolver.prefetch({ ...input, context });
},
open(input) {
void resolver.open({ ...input, context });
},
}),
[context, resolver],
);
}
@@ -74,7 +74,10 @@ import {
type BottomAnchorLocalRequest, type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest, type BottomAnchorRouteRequest,
} from "./use-bottom-anchor-controller"; } from "./use-bottom-anchor-controller";
import { normalizeInlinePathTarget } from "@/assistant-file-links"; import {
AssistantFileLinkResolverProvider,
normalizeInlinePathTarget,
} from "@/assistant-file-links";
import { import {
createWorkspaceFileTabTarget, createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation, normalizeWorkspaceFileLocation,
@@ -83,6 +86,7 @@ import {
} from "@/workspace/file-open"; } from "@/workspace/file-open";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
@@ -322,7 +326,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
setExpandedInlineToolCallIds(new Set()); setExpandedInlineToolCallIds(new Set());
}, [agentId]); }, [agentId]);
const handleInlinePathPress = useCallback( const handleInlinePathPress = useStableEvent(
(target: InlinePathTarget, disposition: OpenFileDisposition) => { (target: InlinePathTarget, disposition: OpenFileDisposition) => {
if (!target.path) { if (!target.path) {
return; return;
@@ -377,25 +381,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
checkout, checkout,
}); });
}, },
[
agent.cwd,
agent.projectPlacement?.checkout?.isGit,
isMobile,
openFileExplorerForCheckout,
onOpenWorkspaceFile,
requestDirectoryListing,
resolvedServerId,
setExplorerTabForCheckout,
workspaceId,
],
); );
const handleToolCallOpenFile = useCallback( const handleToolCallOpenFile = useStableEvent((filePath: string) => {
(filePath: string) => { handleInlinePathPress({ raw: filePath, path: filePath }, "main");
handleInlinePathPress({ raw: filePath, path: filePath }, "main"); });
},
[handleInlinePathPress],
);
const baseRenderModel = useMemo(() => { const baseRenderModel = useMemo(() => {
return buildAgentStreamRenderModel({ return buildAgentStreamRenderModel({
@@ -510,19 +500,25 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
belowItem, belowItem,
}); });
return ( return (
<AssistantMessage <AssistantFileLinkResolverProvider
message={item.text}
timestamp={item.timestamp.getTime()}
onInlinePathPress={handleInlinePathPress}
workspaceRoot={workspaceRoot}
serverId={serverId}
client={client} client={client}
serverId={resolvedServerId}
workspaceRoot={workspaceRoot}
onOpenWorkspaceFile={handleInlinePathPress}
toast={toast} 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( 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 type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import { buildToolCallPresentation } from "@/tool-calls/presentation"; import { buildToolCallPresentation } from "@/tool-calls/presentation";
import { resolveToolCallIcon } from "@/utils/tool-call-icon"; import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import type { OpenFileDisposition } from "@/workspace/file-open";
import { getMarkdownListMarker, getMarkdownNextSiblingType } from "@/utils/markdown-list"; 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 { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
import { formatDuration, formatMessageTimestamp } from "@/utils/time"; import { formatDuration, formatMessageTimestamp } from "@/utils/time";
@@ -92,12 +91,11 @@ import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details"; import { ToolCallDetailsContent } from "./tool-call-details";
import { import {
AssistantInlineCodePathLink, AssistantInlineCodePathLink,
classifyAssistantFileLink,
type AssistantFileLinkSource, type AssistantFileLinkSource,
AssistantMarkdownCodeLink, AssistantMarkdownCodeLink,
AssistantMarkdownLink, AssistantMarkdownLink,
type InlinePathTarget, type InlinePathTarget,
useAssistantFileLinkResolver, useAssistantFileLinkActions,
} from "@/assistant-file-links"; } from "@/assistant-file-links";
import { getCompactionMarkerLabel } from "./message-compaction-label"; import { getCompactionMarkerLabel } from "./message-compaction-label";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
@@ -717,11 +715,9 @@ export const LiveElapsed = memo(function LiveElapsed({
interface AssistantMessageProps { interface AssistantMessageProps {
message: string; message: string;
timestamp: number; timestamp: number;
onInlinePathPress?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void;
workspaceRoot?: string; workspaceRoot?: string;
serverId?: string; serverId?: string;
client?: DaemonClient | null; client?: DaemonClient | null;
toast?: ToastApi | null;
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth"; spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
} }
@@ -1563,11 +1559,9 @@ function MarkdownListView({ baseStyle, marginBottom, children }: MarkdownListVie
export const AssistantMessage = memo(function AssistantMessage({ export const AssistantMessage = memo(function AssistantMessage({
message, message,
timestamp: _timestamp, timestamp: _timestamp,
onInlinePathPress,
workspaceRoot, workspaceRoot,
serverId, serverId,
client, client,
toast,
spacing = "default", spacing = "default",
}: AssistantMessageProps) { }: AssistantMessageProps) {
const markdownParser = useMemo(() => { const markdownParser = useMemo(() => {
@@ -1583,36 +1577,14 @@ export const AssistantMessage = memo(function AssistantMessage({
return parser; return parser;
}, []); }, []);
const fileLinkResolver = useAssistantFileLinkResolver({ const fileLinkActions = useAssistantFileLinkActions();
client, const handleMarkdownLinkPress = useStableEvent((url: string) => {
serverId, fileLinkActions.open({ href: url }, "main");
workspaceRoot, // react-native-markdown-display opens the link itself when this returns true.
onOpenWorkspaceFile: onInlinePathPress, // We already handled it above, so return false to avoid duplicate opens.
toast, 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>(() => { const markdownRules = useMemo<RenderRules>(() => {
return { return {
text: ( text: (
@@ -1684,12 +1656,13 @@ export const AssistantMessage = memo(function AssistantMessage({
) => { ) => {
const content = node.content ?? ""; const content = node.content ?? "";
const isLinkedInlineCode = nodeHasParentType(parent, "link"); const isLinkedInlineCode = nodeHasParentType(parent, "link");
const inlineCodeFileLink = classifyAssistantFileLink(content, { workspaceRoot }); const inlineCodeSource: AssistantFileLinkSource = {
href: content,
text: content,
sourceType: "inline-code",
};
const shouldResolveInlinePath = const shouldResolveInlinePath =
onInlinePathPress && !isLinkedInlineCode && fileLinkActions.canResolveFile(inlineCodeSource);
!isLinkedInlineCode &&
inlineCodeFileLink &&
inlineCodeFileLink.kind !== "external";
if (shouldResolveInlinePath) { if (shouldResolveInlinePath) {
return ( return (
@@ -1699,9 +1672,6 @@ export const AssistantMessage = memo(function AssistantMessage({
inheritedStyles={inheritedStyles} inheritedStyles={inheritedStyles}
codeInlineStyle={styles.code_inline} codeInlineStyle={styles.code_inline}
linkStyle={styles.link} linkStyle={styles.link}
onPress={handleLinkPress}
onPrefetch={handleLinkPrefetch}
workspaceRoot={workspaceRoot}
/> />
); );
} }
@@ -1719,9 +1689,6 @@ export const AssistantMessage = memo(function AssistantMessage({
inheritedStyles={inheritedStyles} inheritedStyles={inheritedStyles}
codeInlineStyle={styles.code_inline} codeInlineStyle={styles.code_inline}
linkStyle={styles.link} linkStyle={styles.link}
onPress={handleLinkPress}
onPrefetch={handleLinkPrefetch}
workspaceRoot={workspaceRoot}
> >
{content} {content}
</AssistantMarkdownCodeLink> </AssistantMarkdownCodeLink>
@@ -1800,9 +1767,6 @@ export const AssistantMessage = memo(function AssistantMessage({
key={node.key} key={node.key}
source={getMarkdownLinkSource(node)} source={getMarkdownLinkSource(node)}
style={styles.link} style={styles.link}
onPress={handleLinkPress}
onPrefetch={handleLinkPrefetch}
workspaceRoot={workspaceRoot}
> >
{Children.map(children, (child) => { {Children.map(children, (child) => {
if (!isValidElement(child)) return child; if (!isValidElement(child)) return child;
@@ -1841,15 +1805,7 @@ export const AssistantMessage = memo(function AssistantMessage({
); );
}, },
}; };
}, [ }, [client, fileLinkActions, markdownParser, serverId, workspaceRoot]);
client,
handleLinkPrefetch,
handleLinkPress,
markdownParser,
onInlinePathPress,
serverId,
workspaceRoot,
]);
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]); const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
const keyedBlocks = useMemo( const keyedBlocks = useMemo(
@@ -32,8 +32,8 @@ export type WorkspaceMatchMode = "fuzzy" | "suffix";
const DEFAULT_LIMIT = 30; const DEFAULT_LIMIT = 30;
const MAX_LIMIT = 100; const MAX_LIMIT = 100;
const DEFAULT_MAX_DEPTH = 6; const DEFAULT_MAX_DEPTH = 12;
const DEFAULT_MAX_DIRECTORIES_SCANNED = 5000; const DEFAULT_MAX_DIRECTORIES_SCANNED = 20000;
const DIRECTORY_LIST_CACHE_TTL_MS = 8_000; const DIRECTORY_LIST_CACHE_TTL_MS = 8_000;
const DIRECTORY_LIST_CACHE_MAX_ENTRIES = 4_000; const DIRECTORY_LIST_CACHE_MAX_ENTRIES = 4_000;