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 {
AssistantInlineCodePathLink,
AssistantInlinePathLink,
AssistantMarkdownCodeLink,
AssistantMarkdownLink,
} from "./link";
@@ -9,5 +8,9 @@ export {
normalizeInlinePathTarget,
type InlinePathTarget,
} from "./parse";
export {
AssistantFileLinkResolverProvider,
type AssistantFileLinkResolverProviderProps,
} from "./provider";
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 {
useCallback,
useMemo,
useState,
type CSSProperties,
type ReactNode,
type MouseEvent,
} from "react";
import { useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
import {
Pressable,
Text,
@@ -16,128 +9,66 @@ import {
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { isNative, isWeb } from "@/constants/platform";
import type { OpenFileDisposition } from "@/workspace/file-open";
import { Shortcut } from "@/components/ui/shortcut";
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";
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>
);
}
import { useFileLink } from "./use-file-link";
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) {
export function AssistantMarkdownLink({ source, style, 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 { target, onHoverIn, onPress, onAuxPress } = useFileLink(source);
const { configRef } = useAssistantFileLinkResolverContext();
const workspaceRoot = configRef.current.workspaceRoot;
const tooltipPath = useMemo(
() => (target ? formatInlinePathTargetForTooltip(target, workspaceRoot) : null),
[target, workspaceRoot],
);
const handlePrefetch = useCallback(() => onPrefetch(source), [onPrefetch, source]);
const handleHoverIn = useCallback(() => {
const handleAnchorClickCapture = useStableEvent((event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
if (!isModifiedOpenEvent(event)) {
return;
}
event.stopPropagation();
onAuxPress();
});
const handleHoverIn = useStableEvent(() => {
setHovered(true);
handlePrefetch();
}, [handlePrefetch]);
const handleHoverOut = useCallback(() => setHovered(false), []);
onHoverIn();
});
const handleHoverOut = useStableEvent(() => 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>
<FileLinkHoverTooltip filePath={tooltipPath}>
<Text accessibilityRole="link" onPress={onPress} style={style}>
{children}
</Text>
</FileLinkHoverTooltip>
);
}
const anchor = (
<a
href={href}
href={source.href}
onClickCapture={handleAnchorClickCapture}
onAuxClickCapture={preventAnchorNavigation}
style={LINK_ANCHOR_STYLE}
>
<Pressable
accessibilityRole="link"
onPress={handlePress}
onFocus={handlePrefetch}
onPress={onPress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
>
@@ -146,10 +77,7 @@ export function AssistantMarkdownLink({
</a>
);
if (tooltipFilePath) {
return <FileLinkHoverTooltip filePath={tooltipFilePath}>{anchor}</FileLinkHoverTooltip>;
}
return anchor;
return <FileLinkHoverTooltip filePath={tooltipPath}>{anchor}</FileLinkHoverTooltip>;
}
interface AssistantMarkdownCodeLinkProps {
@@ -157,9 +85,6 @@ interface AssistantMarkdownCodeLinkProps {
inheritedStyles: TextStyle;
codeInlineStyle: TextStyle;
linkStyle: TextStyle;
onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void;
onPrefetch: (source: AssistantFileLinkSource) => void;
workspaceRoot?: string;
children: ReactNode;
}
@@ -168,9 +93,6 @@ export function AssistantMarkdownCodeLink({
inheritedStyles,
codeInlineStyle,
linkStyle,
onPress,
onPrefetch,
workspaceRoot,
children,
}: AssistantMarkdownCodeLinkProps) {
const style = useMemo(
@@ -178,74 +100,14 @@ export function AssistantMarkdownCodeLink({
[inheritedStyles, codeInlineStyle, linkStyle],
);
return (
<AssistantMarkdownLink
source={source}
style={style}
onPress={onPress}
onPrefetch={onPrefetch}
workspaceRoot={workspaceRoot}
>
<AssistantMarkdownLink source={source} style={style}>
{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,
target: { path: string; lineStart?: number; lineEnd?: number },
workspaceRoot: string | undefined,
): string {
let result = relativizePathToWorkspace(target.path, workspaceRoot);
@@ -276,6 +138,40 @@ function relativizePathToWorkspace(filePath: string, workspaceRoot: string | und
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 = {
// RN doesn't type "inline-flex" but RN-web honors it at runtime, which keeps
// 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"];
function FileLinkHoverTooltip({ filePath, children }: { filePath: string; children: ReactNode }) {
function FileLinkHoverTooltip({
filePath,
children,
}: {
filePath: string | null;
children: ReactNode;
}) {
if (!isWeb) {
return children;
}
@@ -293,19 +195,21 @@ function FileLinkHoverTooltip({ filePath, children }: { filePath: string; childr
<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
{filePath ? (
<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>
</View>
</TooltipContent>
</TooltipContent>
) : null}
</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 {
createAssistantFileLinkResolver,
classifyForResolution,
fetchDaemonResolution,
getAssistantFileLinkToken,
UnresolvedFileLinkError,
type AssistantFileLinkContext,
type DirectorySuggestionEntry,
type DirectorySuggestionResult,
} from "./resolver";
import type { OpenFileDisposition } from "@/workspace/file-open";
import type { InlinePathTarget } from "./parse";
const CONTEXT: AssistantFileLinkContext = {
serverId: "server-1",
workspaceRoot: "/Users/test/project",
};
@@ -20,554 +19,201 @@ function resolvedSuggestions(
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 };
function suggestionsFromMap(entriesByQuery: Record<string, DirectorySuggestionEntry[]>): {
getDirectorySuggestions: ReturnType<typeof vi.fn>;
searches: Array<{
query: string;
cwd: string;
matchMode: "suffix";
limit: number;
}>;
} {
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 {
query: string;
cwd: string;
includeFiles: true;
includeDirectories: false;
matchMode: "suffix";
limit: number;
}
describe("classifyForResolution", () => {
it("returns the directFile target synchronously", () => {
const result = classifyForResolution({ href: "src/components/message.tsx#L33" }, CONTEXT);
interface OpenedFile {
target: InlinePathTarget;
disposition: OpenFileDisposition;
}
class FakeWorkspaceFiles {
readonly searches: DirectorySearch[] = [];
readonly openedFiles: OpenedFile[] = [];
readonly unresolvedTokens: string[] = [];
constructor(private readonly entriesByQuery: Record<string, DirectorySuggestionEntry[]>) {}
createResolver() {
return createAssistantFileLinkResolver({
getDirectorySuggestions: this.getDirectorySuggestions,
openWorkspaceFile: this.openWorkspaceFile,
openExternalUrl: async () => {},
onUnresolvedFileCandidate: this.onUnresolvedFileCandidate,
expect(result).toEqual({
kind: "resolved",
value: {
kind: "file",
target: {
raw: "src/components/message.tsx#L33",
path: "/Users/test/project/src/components/message.tsx",
lineStart: 33,
lineEnd: undefined,
},
},
});
}
});
private getDirectorySuggestions = async (
search: DirectorySearch,
): Promise<DirectorySuggestionResult> => {
this.searches.push(search);
return resolvedSuggestions(this.entriesByQuery[search.query] ?? []);
};
it("preserves line ranges on direct workspace files", () => {
const result = classifyForResolution({ href: "src/components/message.tsx:33-40" }, CONTEXT);
private openWorkspaceFile = (
target: InlinePathTarget,
disposition: OpenFileDisposition,
): void => {
this.openedFiles.push({ target, disposition });
};
expect(result).toEqual({
kind: "resolved",
value: {
kind: "file",
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 => {
this.unresolvedTokens.push(token);
};
}
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" }]),
it("flags basename inline-code as a daemon lookup keyed by suggestion query", () => {
const result = classifyForResolution(
{ href: "file.ts:12", text: "file.ts:12", sourceType: "inline-code" },
CONTEXT,
);
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([
resolver.prefetch({ context: CONTEXT, source }),
resolver.prefetch({ context: CONTEXT, source }),
]);
const result = await resolver.open({ context: CONTEXT, source, disposition: "main" });
expect(suggestions).toHaveBeenCalledTimes(1);
expect(suggestions).toHaveBeenCalledWith({
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,
expect(result).toEqual({
kind: "needsLookup",
ambiguousQuery: "file.ts",
token: "file.ts:12",
target: {
raw: "file.ts:12",
path: "/Users/test/project/file.ts",
lineStart: 12,
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 () => {
const deferred = createDeferred<DirectorySuggestionResult>();
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" };
it("returns ignored for non-file-looking content", () => {
const result = classifyForResolution({ href: "" }, CONTEXT);
const prefetch = resolver.prefetch({ context: CONTEXT, source });
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);
expect(result).toEqual({ kind: "resolved", value: { kind: "ignored" } });
});
});
it("retries a click after hover prefetch fails to query suggestions", async () => {
const suggestions = vi
.fn()
.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({
describe("fetchDaemonResolution", () => {
it("resolves daemon suggestions into workspace file targets", async () => {
const { getDirectorySuggestions, searches } = suggestionsFromMap({
"file.ts": [{ path: "packages/app/src/file.ts", kind: "file" }],
});
const resolver = workspaceFiles.createResolver();
const result = await resolver.open({
context: { ...CONTEXT, workspaceRoot: "/Users/test/project" },
source: {
href: "file.ts:12",
text: "file.ts:12",
sourceType: "inline-code",
const result = await fetchDaemonResolution({
ambiguousQuery: "file.ts",
token: "file.ts:12",
target: {
raw: "file.ts:12",
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",
cwd: "/Users/test/project",
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
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: {
raw: "file.ts:12",
path: "/Users/test/project/packages/app/src/file.ts",
lineStart: 12,
raw: "src/file.ts",
path: "/Users/test/project/src/file.ts",
lineStart: undefined,
lineEnd: undefined,
},
disposition: "main",
},
]);
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");
workspaceRoot: "/Users/test/project",
getDirectorySuggestions,
}),
openWorkspaceFile: vi.fn(),
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,
});
).rejects.toEqual(new UnresolvedFileLinkError("src/file.ts"));
});
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", () => {
expect(
getAssistantFileLinkToken({
+107 -198
View File
@@ -4,12 +4,6 @@ import {
type AssistantFileLinkClassification,
type InlinePathTarget,
} from "./parse";
import type { OpenFileDisposition } from "@/workspace/file-open";
export interface AssistantFileLinkContext {
serverId?: string;
workspaceRoot?: string;
}
export interface AssistantFileLinkSource {
href: string;
@@ -19,6 +13,10 @@ export interface AssistantFileLinkSource {
sourceType?: "inline-code";
}
export interface AssistantFileLinkContext {
workspaceRoot?: string;
}
export interface DirectorySuggestionEntry {
path: string;
kind: "file" | "directory";
@@ -29,152 +27,121 @@ export interface DirectorySuggestionResult {
error: string | null;
}
export interface AssistantFileLinkResolverDependencies {
getDirectorySuggestions: (input: {
query: string;
cwd: string;
includeFiles: true;
includeDirectories: false;
matchMode: "suffix";
limit: number;
}) => 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 GetDirectorySuggestions = (input: {
query: string;
cwd: string;
includeFiles: true;
includeDirectories: false;
matchMode: "suffix";
limit: number;
}) => Promise<DirectorySuggestionResult>;
export type ResolvedAssistantFileLink =
| { kind: "external"; url: string }
| { kind: "file"; target: InlinePathTarget }
| { kind: "ignored" };
export type AssistantFileLinkResolution =
| { kind: "resolved"; value: ResolvedAssistantFileLink }
| {
kind: "external";
url: string;
}
| {
kind: "file";
target: InlinePathTarget;
}
| {
kind: "unresolvedFileCandidate";
kind: "needsLookup";
ambiguousQuery: string;
token: string;
}
| {
kind: "ignored";
target: InlinePathTarget;
};
export type AssistantFileLinkOpenResult = ResolvedAssistantFileLink & {
opened: boolean;
};
type CachedAssistantFileLink = Exclude<ResolvedAssistantFileLink, { kind: "external" }>;
interface ParsedAssistantFileLinkInteraction {
export interface FetchDaemonResolutionInput {
ambiguousQuery: string;
token: string;
classification: AssistantFileLinkClassification;
target: InlinePathTarget;
workspaceRoot?: string;
getDirectorySuggestions: GetDirectorySuggestions;
}
export function createAssistantFileLinkResolver(
dependencies: AssistantFileLinkResolverDependencies,
): AssistantFileLinkResolver {
const cache = new Map<string, CachedAssistantFileLink>();
const inFlight = new Map<string, Promise<CachedAssistantFileLink>>();
export class UnresolvedFileLinkError extends Error {
constructor(readonly token: string) {
super(`No file found for ${token}`);
this.name = "UnresolvedFileLinkError";
}
}
async function resolve(
input: AssistantFileLinkPrefetchInput,
): Promise<ResolvedAssistantFileLink> {
const parsed = parseInteraction(input);
if (!parsed) {
return { kind: "ignored" };
}
export async function fetchDaemonResolution({
ambiguousQuery,
token,
target,
workspaceRoot,
getDirectorySuggestions,
}: FetchDaemonResolutionInput): Promise<InlinePathTarget> {
const trimmedRoot = workspaceRoot?.trim();
if (!trimmedRoot) {
throw new UnresolvedFileLinkError(token);
}
if (parsed.classification.kind === "external") {
return { kind: "external", url: parsed.classification.raw };
}
let suggestions: DirectorySuggestionResult;
try {
suggestions = await getDirectorySuggestions({
query: ambiguousQuery,
cwd: trimmedRoot,
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
limit: 1,
});
} catch {
throw new UnresolvedFileLinkError(token);
}
if (
parsed.classification.kind === "directFile" &&
!shouldResolveDirectFileThroughSuggestions({
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;
const match = suggestions.entries.find((entry) => entry.kind === "file");
if (!match || suggestions.error) {
throw new UnresolvedFileLinkError(token);
}
return {
prefetch(input) {
return resolve(input);
},
async open(input) {
const resolved = await resolve(input);
if (!canApplyResult(input.context, dependencies.isCurrentContext)) {
return { ...resolved, opened: false };
}
...target,
path: joinWorkspacePath(trimmedRoot, match.path),
};
}
if (resolved.kind === "file") {
dependencies.openWorkspaceFile(resolved.target, input.disposition);
return { ...resolved, opened: true };
}
export function classifyForResolution(
source: AssistantFileLinkSource,
context: AssistantFileLinkContext,
): AssistantFileLinkResolution {
const token = getAssistantFileLinkToken(source).trim();
if (!token) {
return { kind: "resolved", value: { kind: "ignored" } };
}
if (resolved.kind === "external") {
await dependencies.openExternalUrl(resolved.url);
return { ...resolved, opened: true };
}
const classification = classifyAssistantFileLink(token, {
workspaceRoot: context.workspaceRoot,
});
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") {
dependencies.onUnresolvedFileCandidate?.(resolved.token);
}
const workspaceRoot = context.workspaceRoot?.trim();
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;
}
function parseInteraction(
input: AssistantFileLinkPrefetchInput,
): ParsedAssistantFileLinkInteraction | null {
const token = getAssistantFileLinkToken(input.source).trim();
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 {
export function getAmbiguousSuggestionQuery(
target: InlinePathTarget,
workspaceRoot: string,
): string {
const normalizedRoot = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
const normalizedPath = target.path.replace(/\\/g, "/");
const prefix = `${normalizedRoot}/`;
@@ -253,7 +171,7 @@ function getAmbiguousSuggestionQuery(target: InlinePathTarget, workspaceRoot: st
return lastSlash >= 0 ? normalizedPath.slice(lastSlash + 1) : normalizedPath;
}
function shouldResolveDirectFileThroughSuggestions(input: {
export function shouldResolveDirectFileThroughSuggestions(input: {
context: AssistantFileLinkContext;
source: AssistantFileLinkSource;
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 {
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 {
const root = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
const child = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
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 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(
@@ -32,8 +32,8 @@ export type WorkspaceMatchMode = "fuzzy" | "suffix";
const DEFAULT_LIMIT = 30;
const MAX_LIMIT = 100;
const DEFAULT_MAX_DEPTH = 6;
const DEFAULT_MAX_DIRECTORIES_SCANNED = 5000;
const DEFAULT_MAX_DEPTH = 12;
const DEFAULT_MAX_DIRECTORIES_SCANNED = 20000;
const DIRECTORY_LIST_CACHE_TTL_MS = 8_000;
const DIRECTORY_LIST_CACHE_MAX_ENTRIES = 4_000;