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