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:
@@ -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";
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user