diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx
index 7bd100e84..7de6bf523 100644
--- a/packages/app/src/components/agent-stream-view.tsx
+++ b/packages/app/src/components/agent-stream-view.tsx
@@ -418,11 +418,12 @@ const AgentStreamViewComponent = forwardRef
);
},
- [handleInlinePathPress, streamRenderStrategy, workspaceRoot, serverId, client],
+ [handleInlinePathPress, streamRenderStrategy, workspaceRoot, serverId, client, toast],
);
const renderThoughtItem = useCallback(
diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx
index 8864a778a..95ca9e1b2 100644
--- a/packages/app/src/components/message.tsx
+++ b/packages/app/src/components/message.tsx
@@ -68,13 +68,11 @@ import type { AgentAttachment } from "@server/shared/messages";
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
import { buildToolCallPresentation } from "@/tool-calls/presentation";
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
-import {
- parseAssistantFileLink,
- parseInlinePathToken,
- type InlinePathTarget,
-} from "@/utils/inline-path";
+import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path";
import { getMarkdownListMarker } from "@/utils/markdown-list";
-import { openExternalUrl } from "@/utils/open-external-url";
+import { type AssistantFileLinkSource } from "@/utils/assistant-file-link-resolver";
+import { useAssistantFileLinkResolver } from "@/hooks/use-assistant-file-link-resolver";
+import type { ToastApi } from "@/components/toast-host";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
import {
getAssistantImageLoadStateFromMetadata,
@@ -545,6 +543,7 @@ interface AssistantMessageProps {
workspaceRoot?: string;
serverId?: string;
client?: DaemonClient | null;
+ toast?: ToastApi | null;
disableOuterSpacing?: boolean;
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
}
@@ -877,19 +876,26 @@ function InlinePathChip({ content, parsed, onPress }: InlinePathChipProps) {
}
function MarkdownLink({
- href,
+ source,
style,
onPress,
+ onPrefetch,
children,
}: {
- href: string;
+ source: AssistantFileLinkSource;
style: StyleProp;
- onPress: (url: string) => void;
+ onPress: (source: AssistantFileLinkSource) => void;
+ onPrefetch: (source: AssistantFileLinkSource) => void;
children: ReactNode;
}) {
const [hovered, setHovered] = useState(false);
- const handlePress = useCallback(() => onPress(href), [onPress, href]);
- const handleHoverIn = useCallback(() => setHovered(true), []);
+ const href = source.href;
+ const handlePress = useCallback(() => onPress(source), [onPress, source]);
+ const handlePrefetch = useCallback(() => onPrefetch(source), [onPrefetch, source]);
+ const handleHoverIn = useCallback(() => {
+ setHovered(true);
+ handlePrefetch();
+ }, [handlePrefetch]);
const handleHoverOut = useCallback(() => setHovered(false), []);
const hoveredTextStyle = useMemo>(
() => [style, hovered && { textDecorationLine: "underline" as const }],
@@ -913,6 +919,7 @@ function MarkdownLink({
@@ -941,11 +948,13 @@ function getInlineCodeAutoLinkUrl(
return null;
}
- const matches: Array<{
- index: number;
- lastIndex: number;
- url: string;
- }> | null = markdownParser.linkify.match(trimmed);
+ const matches:
+ | {
+ index: number;
+ lastIndex: number;
+ url: string;
+ }[]
+ | null = markdownParser.linkify.match(trimmed);
if (!matches || matches.length !== 1) {
return null;
}
@@ -958,6 +967,40 @@ function getInlineCodeAutoLinkUrl(
return match.url;
}
+function getInlineCodeAutoLinkSource(input: {
+ href: string;
+ content: string;
+}): AssistantFileLinkSource {
+ return {
+ href: input.href,
+ text: input.content,
+ markup: "linkify",
+ sourceInfo: "auto",
+ };
+}
+
+interface AssistantMarkdownAstNode extends ASTNode {
+ sourceInfo?: string;
+}
+
+function getMarkdownLinkSource(node: AssistantMarkdownAstNode): AssistantFileLinkSource {
+ return {
+ href: typeof node.attributes?.href === "string" ? node.attributes.href : "",
+ text: getMarkdownNodeText(node),
+ markup: node.markup,
+ sourceInfo: node.sourceInfo,
+ sourceType: node.sourceType,
+ };
+}
+
+function getMarkdownNodeText(node: ASTNode): string {
+ if (!node.children.length) {
+ return node.content ?? "";
+ }
+
+ return node.children.map(getMarkdownNodeText).join("");
+}
+
function nodeHasParentType(parent: unknown, type: string): boolean {
if (Array.isArray(parent)) {
return parent.some((entry) => entry?.type === type);
@@ -1394,20 +1437,22 @@ function MarkdownInheritedText({
}
interface MarkdownInheritedCodeLinkProps {
- href: string;
+ source: AssistantFileLinkSource;
inheritedStyles: TextStyle;
codeInlineStyle: TextStyle;
linkStyle: TextStyle;
- onPress: (url: string) => boolean;
+ onPress: (source: AssistantFileLinkSource) => void;
+ onPrefetch: (source: AssistantFileLinkSource) => void;
children: ReactNode;
}
function MarkdownInheritedCodeLink({
- href,
+ source,
inheritedStyles,
codeInlineStyle,
linkStyle,
onPress,
+ onPrefetch,
children,
}: MarkdownInheritedCodeLinkProps) {
const style = useMemo(
@@ -1415,7 +1460,7 @@ function MarkdownInheritedCodeLink({
[inheritedStyles, codeInlineStyle, linkStyle],
);
return (
-
+
{children}
);
@@ -1452,6 +1497,7 @@ export const AssistantMessage = memo(function AssistantMessage({
workspaceRoot,
serverId,
client,
+ toast,
disableOuterSpacing,
spacing = "default",
}: AssistantMessageProps) {
@@ -1472,20 +1518,34 @@ export const AssistantMessage = memo(function AssistantMessage({
return parser;
}, []);
- const handleLinkPress = useCallback(
- (url: string) => {
- const fileTarget = onInlinePathPress ? parseAssistantFileLink(url, { workspaceRoot }) : null;
- if (fileTarget) {
- onInlinePathPress?.(fileTarget);
- return false;
- }
+ const fileLinkResolver = useAssistantFileLinkResolver({
+ client,
+ serverId,
+ workspaceRoot,
+ onOpenWorkspaceFile: onInlinePathPress,
+ toast,
+ });
- void openExternalUrl(url);
+ const handleLinkPress = useCallback(
+ (source: AssistantFileLinkSource) => {
+ fileLinkResolver.open({ source });
+ },
+ [fileLinkResolver],
+ );
+ const handleLinkPrefetch = useCallback(
+ (source: AssistantFileLinkSource) => {
+ fileLinkResolver.prefetch({ source });
+ },
+ [fileLinkResolver],
+ );
+ const handleMarkdownLinkPress = useCallback(
+ (url: string) => {
+ fileLinkResolver.open({ source: { href: url } });
// 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;
},
- [onInlinePathPress, workspaceRoot],
+ [fileLinkResolver],
);
const markdownRules = useMemo(() => {
@@ -1575,14 +1635,19 @@ export const AssistantMessage = memo(function AssistantMessage({
const inlineCodeLinkUrl = getInlineCodeAutoLinkUrl(markdownParser, content);
if (inlineCodeLinkUrl) {
+ const source = getInlineCodeAutoLinkSource({
+ href: inlineCodeLinkUrl,
+ content,
+ });
return (
{content}
@@ -1651,9 +1716,10 @@ export const AssistantMessage = memo(function AssistantMessage({
link: (node: ASTNode, children: ReactNode[], _parent: ASTNode[], styles: MarkdownStyles) => (
{Children.map(children, (child) => {
if (!isValidElement(child)) return child;
@@ -1692,7 +1758,15 @@ export const AssistantMessage = memo(function AssistantMessage({
);
},
};
- }, [client, handleLinkPress, markdownParser, onInlinePathPress, serverId, workspaceRoot]);
+ }, [
+ client,
+ handleLinkPrefetch,
+ handleLinkPress,
+ markdownParser,
+ onInlinePathPress,
+ serverId,
+ workspaceRoot,
+ ]);
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
const keyedBlocks = useMemo(
@@ -1724,7 +1798,7 @@ export const AssistantMessage = memo(function AssistantMessage({
text={block}
rules={markdownRules}
parser={markdownParser}
- onLinkPress={handleLinkPress}
+ onLinkPress={handleMarkdownLinkPress}
/>
))}
diff --git a/packages/app/src/hooks/use-assistant-file-link-resolver.ts b/packages/app/src/hooks/use-assistant-file-link-resolver.ts
new file mode 100644
index 000000000..bad89efbf
--- /dev/null
+++ b/packages/app/src/hooks/use-assistant-file-link-resolver.ts
@@ -0,0 +1,88 @@
+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 AssistantFileLinkInteraction,
+} from "@/utils/assistant-file-link-resolver";
+import type { InlinePathTarget } from "@/utils/inline-path";
+import { openExternalUrl } from "@/utils/open-external-url";
+
+export interface UseAssistantFileLinkResolverOptions {
+ client?: DaemonClient | null;
+ serverId?: string;
+ workspaceRoot?: string;
+ onOpenWorkspaceFile?: (target: InlinePathTarget) => void;
+ toast?: ToastApi | null;
+}
+
+export interface AssistantFileLinkActions {
+ prefetch(input: Omit): void;
+ open(input: Omit): 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) {
+ onOpenWorkspaceFile?.(target);
+ },
+ 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],
+ );
+}
diff --git a/packages/app/src/utils/assistant-file-link-resolver.test.ts b/packages/app/src/utils/assistant-file-link-resolver.test.ts
new file mode 100644
index 000000000..2e279d84d
--- /dev/null
+++ b/packages/app/src/utils/assistant-file-link-resolver.test.ts
@@ -0,0 +1,395 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ createAssistantFileLinkResolver,
+ getAssistantFileLinkToken,
+ type AssistantFileLinkContext,
+ type DirectorySuggestionResult,
+} from "./assistant-file-link-resolver";
+
+const CONTEXT: AssistantFileLinkContext = {
+ serverId: "server-1",
+ workspaceRoot: "/Users/test/project",
+};
+
+function resolvedSuggestions(
+ entries: DirectorySuggestionResult["entries"],
+): DirectorySuggestionResult {
+ return { entries, error: null };
+}
+
+function createDeferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (error: unknown) => void;
+ const promise = new Promise((promiseResolve, promiseReject) => {
+ resolve = promiseResolve;
+ reject = promiseReject;
+ });
+
+ return { promise, resolve, reject };
+}
+
+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([
+ resolver.prefetch({ context: CONTEXT, source }),
+ resolver.prefetch({ context: CONTEXT, source }),
+ ]);
+ const result = await resolver.open({ context: CONTEXT, source });
+
+ expect(suggestions).toHaveBeenCalledTimes(1);
+ expect(suggestions).toHaveBeenCalledWith({
+ query: "dumm.md",
+ cwd: "/Users/test/project",
+ includeFiles: true,
+ includeDirectories: false,
+ limit: 1,
+ });
+ expect(openWorkspaceFile).toHaveBeenCalledWith({
+ raw: "dumm.md",
+ path: "/Users/test/project/src/dumm.md",
+ lineStart: undefined,
+ lineEnd: undefined,
+ });
+ expect(openExternalUrl).not.toHaveBeenCalled();
+ expect(result.opened).toBe(true);
+ });
+
+ it("click consumes an in-flight hover resolution", async () => {
+ const deferred = createDeferred();
+ 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 });
+ const opened = resolver.open({ context: CONTEXT, source });
+ 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,
+ });
+ expect(result.opened).toBe(true);
+ });
+
+ it("retries a click after hover prefetch fails to query suggestions", async () => {
+ const suggestions = vi
+ .fn()
+ .mockRejectedValueOnce(new Error("daemon unavailable"))
+ .mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
+ const openWorkspaceFile = vi.fn();
+ const openExternalUrl = vi.fn();
+ const resolver = createAssistantFileLinkResolver({
+ getDirectorySuggestions: suggestions,
+ openWorkspaceFile,
+ openExternalUrl,
+ });
+ const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" };
+
+ const prefetchResult = await resolver.prefetch({ context: CONTEXT, source });
+ const openResult = await resolver.open({ context: CONTEXT, source });
+
+ 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,
+ });
+ 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 });
+ const second = await resolver.open({ context: CONTEXT, source });
+
+ 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,
+ });
+ 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 });
+ await resolver.open({
+ context: { serverId: "server-1", workspaceRoot: "/Users/test/other" },
+ source,
+ });
+
+ expect(suggestions).toHaveBeenCalledTimes(2);
+ expect(openWorkspaceFile).toHaveBeenLastCalledWith({
+ raw: "dumm.md",
+ path: "/Users/test/other/two/dumm.md",
+ lineStart: undefined,
+ lineEnd: undefined,
+ });
+ });
+
+ it("does not apply stale async results after the active context changes", async () => {
+ const deferred = createDeferred();
+ 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" },
+ });
+ 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" },
+ });
+
+ 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,
+ });
+ expect(result.opened).toBe(true);
+ });
+
+ 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" },
+ });
+
+ 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" },
+ });
+
+ 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" },
+ });
+
+ 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" },
+ });
+
+ 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(),
+ openExternalUrl,
+ onUnresolvedFileCandidate,
+ });
+
+ const result = await resolver.open({
+ context: CONTEXT,
+ source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" },
+ });
+
+ expect(openExternalUrl).not.toHaveBeenCalled();
+ expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md");
+ expect(result).toEqual({
+ kind: "unresolvedFileCandidate",
+ token: "dumm.md",
+ opened: false,
+ });
+ });
+
+ it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => {
+ expect(
+ getAssistantFileLinkToken({
+ href: "http://dumm.md",
+ text: "dumm.md",
+ markup: "linkify",
+ sourceInfo: "auto",
+ }),
+ ).toBe("dumm.md");
+ expect(
+ getAssistantFileLinkToken({
+ href: "http://google.com",
+ text: "google.com",
+ markup: "linkify",
+ sourceInfo: "auto",
+ }),
+ ).toBe("http://google.com");
+ expect(
+ getAssistantFileLinkToken({
+ href: "http://dumm.md",
+ text: "dumm.md",
+ markup: "",
+ sourceInfo: "",
+ }),
+ ).toBe("http://dumm.md");
+ });
+});
diff --git a/packages/app/src/utils/assistant-file-link-resolver.ts b/packages/app/src/utils/assistant-file-link-resolver.ts
new file mode 100644
index 000000000..ccde952b7
--- /dev/null
+++ b/packages/app/src/utils/assistant-file-link-resolver.ts
@@ -0,0 +1,258 @@
+import {
+ classifyAssistantFileLink,
+ isFileLookingAssistantToken,
+ type AssistantFileLinkClassification,
+ type InlinePathTarget,
+} from "./inline-path";
+
+export interface AssistantFileLinkContext {
+ serverId?: string;
+ workspaceRoot?: string;
+}
+
+export interface AssistantFileLinkSource {
+ href: string;
+ text?: string;
+ markup?: string;
+ sourceInfo?: string;
+ sourceType?: string;
+}
+
+export interface DirectorySuggestionEntry {
+ path: string;
+ kind: "file" | "directory";
+}
+
+export interface DirectorySuggestionResult {
+ entries: DirectorySuggestionEntry[];
+ error: string | null;
+}
+
+export interface AssistantFileLinkResolverDependencies {
+ getDirectorySuggestions: (input: {
+ query: string;
+ cwd: string;
+ includeFiles: true;
+ includeDirectories: false;
+ limit: number;
+ }) => Promise;
+ openWorkspaceFile: (target: InlinePathTarget) => void;
+ openExternalUrl: (url: string) => void | Promise;
+ onUnresolvedFileCandidate?: (token: string) => void;
+ isCurrentContext?: (context: AssistantFileLinkContext) => boolean;
+}
+
+export interface AssistantFileLinkResolver {
+ prefetch(input: AssistantFileLinkInteraction): Promise;
+ open(input: AssistantFileLinkInteraction): Promise;
+}
+
+export interface AssistantFileLinkInteraction {
+ context: AssistantFileLinkContext;
+ source: AssistantFileLinkSource;
+}
+
+export type ResolvedAssistantFileLink =
+ | {
+ kind: "external";
+ url: string;
+ }
+ | {
+ kind: "file";
+ target: InlinePathTarget;
+ }
+ | {
+ kind: "unresolvedFileCandidate";
+ token: string;
+ }
+ | {
+ kind: "ignored";
+ };
+
+export type AssistantFileLinkOpenResult = ResolvedAssistantFileLink & {
+ opened: boolean;
+};
+
+type CachedAssistantFileLink = Exclude;
+
+interface ParsedAssistantFileLinkInteraction {
+ token: string;
+ classification: AssistantFileLinkClassification;
+}
+
+export function createAssistantFileLinkResolver(
+ dependencies: AssistantFileLinkResolverDependencies,
+): AssistantFileLinkResolver {
+ const cache = new Map();
+ const inFlight = new Map>();
+
+ async function resolve(input: AssistantFileLinkInteraction): Promise {
+ const parsed = parseInteraction(input);
+ if (!parsed) {
+ return { kind: "ignored" };
+ }
+
+ if (parsed.classification.kind === "external") {
+ return { kind: "external", url: parsed.classification.raw };
+ }
+
+ if (parsed.classification.kind === "directFile") {
+ 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 {
+ prefetch(input) {
+ return resolve(input);
+ },
+ async open(input) {
+ const resolved = await resolve(input);
+ if (!canApplyResult(input.context, dependencies.isCurrentContext)) {
+ return { ...resolved, opened: false };
+ }
+
+ if (resolved.kind === "file") {
+ dependencies.openWorkspaceFile(resolved.target);
+ return { ...resolved, opened: true };
+ }
+
+ if (resolved.kind === "external") {
+ await dependencies.openExternalUrl(resolved.url);
+ return { ...resolved, opened: true };
+ }
+
+ if (resolved.kind === "unresolvedFileCandidate") {
+ dependencies.onUnresolvedFileCandidate?.(resolved.token);
+ }
+
+ return { ...resolved, opened: false };
+ },
+ };
+}
+
+export function getAssistantFileLinkToken(source: AssistantFileLinkSource): string {
+ if (isLinkifiedSource(source)) {
+ const text = source.text?.trim();
+ if (text && isFileLookingAssistantToken(text)) {
+ return text;
+ }
+ }
+
+ return source.href;
+}
+
+function parseInteraction(
+ input: AssistantFileLinkInteraction,
+): ParsedAssistantFileLinkInteraction | null {
+ const token = getAssistantFileLinkToken(input.source).trim();
+ if (!token) {
+ return null;
+ }
+
+ const classification = classifyAssistantFileLink(token, {
+ workspaceRoot: input.context.workspaceRoot,
+ });
+ if (!classification) {
+ return null;
+ }
+
+ return { token, classification };
+}
+
+async function resolveAmbiguousCandidate(input: {
+ context: AssistantFileLinkContext;
+ token: string;
+ target: InlinePathTarget;
+ getDirectorySuggestions: AssistantFileLinkResolverDependencies["getDirectorySuggestions"];
+}): Promise {
+ 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,
+ 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 normalizedPath = target.path.replace(/\\/g, "/");
+ const prefix = `${normalizedRoot}/`;
+ if (normalizedPath.startsWith(prefix)) {
+ return normalizedPath.slice(prefix.length);
+ }
+
+ const lastSlash = normalizedPath.lastIndexOf("/");
+ return lastSlash >= 0 ? normalizedPath.slice(lastSlash + 1) : normalizedPath;
+}
+
+function getResolutionKey(context: AssistantFileLinkContext, token: string): string {
+ return [context.serverId ?? "", context.workspaceRoot ?? "", token].join("\0");
+}
+
+function isLinkifiedSource(source: AssistantFileLinkSource): boolean {
+ return source.markup === "linkify" || source.sourceInfo === "auto";
+}
+
+function canApplyResult(
+ context: AssistantFileLinkContext,
+ isCurrentContext: AssistantFileLinkResolverDependencies["isCurrentContext"],
+): boolean {
+ return isCurrentContext ? isCurrentContext(context) : true;
+}
+
+function joinWorkspacePath(workspaceRoot: string, relativePath: string): string {
+ const root = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, "");
+ const child = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
+ return root ? `${root}/${child}` : child;
+}
diff --git a/packages/app/src/utils/inline-path.test.ts b/packages/app/src/utils/inline-path.test.ts
index a641a897d..866bece0f 100644
--- a/packages/app/src/utils/inline-path.test.ts
+++ b/packages/app/src/utils/inline-path.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
+ classifyAssistantFileLink,
normalizeInlinePathTarget,
parseAssistantFileLink,
parseFileProtocolUrl,
@@ -70,7 +71,143 @@ describe("parseFileProtocolUrl", () => {
});
});
+describe("classifyAssistantFileLink", () => {
+ it("keeps explicit external URLs out of file parsing", () => {
+ expect(
+ classifyAssistantFileLink("http://dumm.md", {
+ workspaceRoot: "/Users/test/project",
+ }),
+ ).toEqual({
+ kind: "external",
+ raw: "http://dumm.md",
+ });
+ expect(classifyAssistantFileLink("mailto:test@example.com")).toEqual({
+ kind: "external",
+ raw: "mailto:test@example.com",
+ });
+ });
+
+ it("classifies bare workspace candidates separately from direct relative files", () => {
+ expect(
+ classifyAssistantFileLink("dumm.md", {
+ workspaceRoot: "/Users/test/project",
+ }),
+ ).toEqual({
+ kind: "ambiguousFileCandidate",
+ target: {
+ raw: "dumm.md",
+ path: "/Users/test/project/dumm.md",
+ lineStart: undefined,
+ lineEnd: undefined,
+ },
+ });
+
+ expect(
+ classifyAssistantFileLink("message-renderer.tsx", {
+ workspaceRoot: "/Users/test/project",
+ }),
+ ).toEqual({
+ kind: "ambiguousFileCandidate",
+ target: {
+ raw: "message-renderer.tsx",
+ path: "/Users/test/project/message-renderer.tsx",
+ lineStart: undefined,
+ lineEnd: undefined,
+ },
+ });
+
+ expect(
+ classifyAssistantFileLink("src/components/message.tsx#L33", {
+ workspaceRoot: "/Users/test/project",
+ }),
+ ).toEqual({
+ kind: "directFile",
+ target: {
+ raw: "src/components/message.tsx#L33",
+ path: "/Users/test/project/src/components/message.tsx",
+ lineStart: 33,
+ lineEnd: undefined,
+ },
+ });
+ });
+
+ it("does not classify normal bare domains as file candidates", () => {
+ expect(
+ classifyAssistantFileLink("google.com", {
+ workspaceRoot: "/Users/test/project",
+ }),
+ ).toBeNull();
+ expect(
+ classifyAssistantFileLink("example.com", {
+ workspaceRoot: "/Users/test/project",
+ }),
+ ).toBeNull();
+ expect(
+ classifyAssistantFileLink("openai.com/path", {
+ workspaceRoot: "/Users/test/project",
+ }),
+ ).toBeNull();
+ });
+});
+
describe("parseAssistantFileLink", () => {
+ it("resolves bare markdown filenames against the active workspace", () => {
+ expect(
+ parseAssistantFileLink("dumm.md", {
+ workspaceRoot: "/Users/test/project",
+ }),
+ ).toEqual({
+ raw: "dumm.md",
+ path: "/Users/test/project/dumm.md",
+ lineStart: undefined,
+ lineEnd: undefined,
+ });
+ });
+
+ it("resolves bare source filenames with line suffixes against the active workspace", () => {
+ expect(
+ parseAssistantFileLink("file.ts:12", {
+ workspaceRoot: "/Users/test/project",
+ }),
+ ).toEqual({
+ raw: "file.ts:12",
+ path: "/Users/test/project/file.ts",
+ lineStart: 12,
+ lineEnd: undefined,
+ });
+ });
+
+ it("rejects bare domains and domain-like paths", () => {
+ expect(
+ parseAssistantFileLink("google.com", {
+ workspaceRoot: "/Users/test/project",
+ }),
+ ).toBeNull();
+ expect(
+ parseAssistantFileLink("google.com:80", {
+ workspaceRoot: "/Users/test/project",
+ }),
+ ).toBeNull();
+ expect(
+ parseAssistantFileLink("openai.com/path", {
+ workspaceRoot: "/Users/test/project",
+ }),
+ ).toBeNull();
+ });
+
+ it("resolves relative paths against the active workspace", () => {
+ expect(
+ parseAssistantFileLink("src/components/message.tsx#L33", {
+ workspaceRoot: "/Users/test/project",
+ }),
+ ).toEqual({
+ raw: "src/components/message.tsx#L33",
+ path: "/Users/test/project/src/components/message.tsx",
+ lineStart: 33,
+ lineEnd: undefined,
+ });
+ });
+
it("parses absolute POSIX hrefs inside the active workspace", () => {
expect(
parseAssistantFileLink("/Users/test/project/src/app.tsx#L33", {
@@ -146,6 +283,11 @@ describe("parseAssistantFileLink", () => {
it("rejects external URLs", () => {
expect(parseAssistantFileLink("https://example.com/Users/test/project/src/app.tsx")).toBeNull();
+ expect(
+ parseAssistantFileLink("http://dumm.md", {
+ workspaceRoot: "/Users/test/project",
+ }),
+ ).toBeNull();
});
it("rejects invalid line fragments", () => {
diff --git a/packages/app/src/utils/inline-path.ts b/packages/app/src/utils/inline-path.ts
index fcdd506db..c32c5bc3e 100644
--- a/packages/app/src/utils/inline-path.ts
+++ b/packages/app/src/utils/inline-path.ts
@@ -9,11 +9,82 @@ export interface InlinePathTarget {
const FILE_PROTOCOL = "file:";
const INLINE_LINE_FRAGMENT = /^L([0-9]+)(?:-L?([0-9]+))?$/i;
+const ASSISTANT_FILE_EXTENSIONS = new Set([
+ "astro",
+ "bash",
+ "c",
+ "cc",
+ "cjs",
+ "cpp",
+ "cs",
+ "css",
+ "cts",
+ "cxx",
+ "env",
+ "fish",
+ "go",
+ "gql",
+ "gradle",
+ "graphql",
+ "h",
+ "hpp",
+ "htm",
+ "html",
+ "ini",
+ "java",
+ "js",
+ "json",
+ "jsonc",
+ "jsx",
+ "kt",
+ "kts",
+ "less",
+ "lock",
+ "lua",
+ "md",
+ "mdx",
+ "mjs",
+ "mts",
+ "php",
+ "proto",
+ "py",
+ "rb",
+ "rs",
+ "sass",
+ "scss",
+ "sh",
+ "sql",
+ "svelte",
+ "swift",
+ "toml",
+ "ts",
+ "tsx",
+ "txt",
+ "vue",
+ "xml",
+ "yaml",
+ "yml",
+ "zsh",
+]);
export interface AssistantHrefParseOptions {
workspaceRoot?: string;
}
+export type AssistantFileLinkClassification =
+ | {
+ kind: "external";
+ raw: string;
+ }
+ | {
+ kind: "directFile";
+ target: InlinePathTarget;
+ }
+ | {
+ kind: "ambiguousFileCandidate";
+ target: InlinePathTarget;
+ };
+
export interface NormalizedInlinePathTarget {
directory: string;
file?: string;
@@ -172,6 +243,41 @@ function parseAssistantInlinePathLink(
};
}
+export function classifyAssistantFileLink(
+ value: string,
+ options: AssistantHrefParseOptions = {},
+): AssistantFileLinkClassification | null {
+ const raw = value ?? "";
+ const trimmed = raw.trim();
+ if (!trimmed) {
+ return null;
+ }
+
+ if (isExternalHref(trimmed)) {
+ return {
+ kind: "external",
+ raw,
+ };
+ }
+
+ const target = parseAssistantFileLink(trimmed, options);
+ if (!target) {
+ return null;
+ }
+
+ if (isAmbiguousWorkspaceCandidate(trimmed, target, options.workspaceRoot)) {
+ return {
+ kind: "ambiguousFileCandidate",
+ target,
+ };
+ }
+
+ return {
+ kind: "directFile",
+ target,
+ };
+}
+
export function parseAssistantFileLink(
value: string,
options: AssistantHrefParseOptions = {},
@@ -186,7 +292,7 @@ export function parseAssistantFileLink(
return null;
}
- if (trimmed.includes("://")) {
+ if (isExternalHref(trimmed)) {
return null;
}
@@ -216,6 +322,13 @@ export function parseAssistantFileLink(
};
}
+ const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, {
+ workspaceRoot: options.workspaceRoot,
+ });
+ if (relativeTarget) {
+ return relativeTarget;
+ }
+
if (!isAbsolutePath(trimmed)) {
return null;
}
@@ -248,6 +361,91 @@ export function parseAssistantFileLink(
};
}
+export function isFileLookingAssistantToken(value: string): boolean {
+ const normalized = normalizePathToken(value);
+ if (!normalized || normalized.includes("?") || normalized.includes("://")) {
+ return false;
+ }
+
+ const path = getHeuristicLocalPath(normalized);
+ if (!path) {
+ return false;
+ }
+
+ return isPlausibleAssistantLocalPath(path);
+}
+
+function parseWorkspaceRelativeFileLink(
+ value: string,
+ options: AssistantHrefParseOptions,
+): InlinePathTarget | null {
+ const workspaceRoot = normalizePathInput(options.workspaceRoot);
+ if (!workspaceRoot) {
+ return null;
+ }
+
+ const parsed = parseLocalPathParts(value);
+ if (!parsed || isAbsolutePath(parsed.path)) {
+ return null;
+ }
+
+ const normalizedPath = resolveRelativePathUnderRoot(parsed.path, workspaceRoot);
+ if (!normalizedPath) {
+ return null;
+ }
+
+ return {
+ raw: value,
+ path: normalizedPath,
+ ...parsed.lines,
+ };
+}
+
+function parseLocalPathParts(
+ value: string,
+): { path: string; lines: Pick } | null {
+ const normalized = normalizePathToken(value);
+ if (!normalized || normalized.includes("?")) {
+ return null;
+ }
+
+ const hashIndex = normalized.indexOf("#");
+ const beforeHash = hashIndex >= 0 ? normalized.slice(0, hashIndex) : normalized;
+ const hash = hashIndex >= 0 ? normalized.slice(hashIndex) : "";
+ const fragmentLines = parseLineFragment(hash);
+ if (!fragmentLines) {
+ return null;
+ }
+
+ const inlinePathTarget = parseInlinePathToken(beforeHash);
+ if (inlinePathTarget) {
+ if (!isPlausibleAssistantLocalPath(inlinePathTarget.path)) {
+ return null;
+ }
+
+ return {
+ path: inlinePathTarget.path,
+ lines: {
+ lineStart: inlinePathTarget.lineStart,
+ lineEnd: inlinePathTarget.lineEnd,
+ },
+ };
+ }
+
+ if (!beforeHash || beforeHash.includes(":")) {
+ return null;
+ }
+
+ if (!isPlausibleAssistantLocalPath(beforeHash)) {
+ return null;
+ }
+
+ return {
+ path: beforeHash,
+ lines: fragmentLines,
+ };
+}
+
export function normalizeInlinePathTarget(
rawPath: string,
cwd?: string,
@@ -308,6 +506,130 @@ function isAllowedAbsolutePath(pathValue: string, workspaceRoot?: string): boole
return comparePath === compareWorkspaceRoot || comparePath.startsWith(comparePrefix);
}
+function isExternalHref(value: string): boolean {
+ if (value.includes("://")) {
+ return !value.toLowerCase().startsWith(`${FILE_PROTOCOL}//`);
+ }
+
+ const inlinePathTarget = parseInlinePathToken(value);
+ if (inlinePathTarget) {
+ return false;
+ }
+
+ return /^[A-Za-z][A-Za-z0-9+.-]*:/.test(value) && !/^[A-Za-z]:[\\/]/.test(value);
+}
+
+function isAmbiguousWorkspaceCandidate(
+ value: string,
+ target: InlinePathTarget,
+ workspaceRoot?: string,
+): boolean {
+ const normalizedWorkspaceRoot = normalizePathInput(workspaceRoot);
+ if (!normalizedWorkspaceRoot || !isAllowedAbsolutePath(target.path, normalizedWorkspaceRoot)) {
+ return false;
+ }
+
+ const parsed = parseLocalPathParts(value);
+ if (!parsed || isAbsolutePath(parsed.path)) {
+ return false;
+ }
+
+ return !parsed.path.includes("/");
+}
+
+function getHeuristicLocalPath(value: string): string | null {
+ const hashIndex = value.indexOf("#");
+ const beforeHash = hashIndex >= 0 ? value.slice(0, hashIndex) : value;
+ const hash = hashIndex >= 0 ? value.slice(hashIndex) : "";
+ if (!parseLineFragment(hash)) {
+ return null;
+ }
+
+ const inlinePathTarget = parseInlinePathToken(beforeHash);
+ if (inlinePathTarget) {
+ return inlinePathTarget.path;
+ }
+
+ if (!beforeHash || beforeHash.includes(":")) {
+ return null;
+ }
+
+ return beforeHash;
+}
+
+function isPlausibleAssistantLocalPath(pathValue: string): boolean {
+ const normalized = normalizePathToken(pathValue);
+ if (!normalized) {
+ return false;
+ }
+
+ if (isAbsolutePath(normalized)) {
+ return true;
+ }
+
+ const explicitRelative =
+ normalized.startsWith("./") || normalized.startsWith("../") || normalized.startsWith("~/");
+ if (explicitRelative) {
+ return true;
+ }
+
+ const segments = normalized.split("/").filter(Boolean);
+ const firstSegment = segments[0];
+ if (!firstSegment) {
+ return false;
+ }
+
+ if (segments.length > 1) {
+ return !isDomainLikePathSegment(firstSegment);
+ }
+
+ if (firstSegment.startsWith(".") && firstSegment.length > 1) {
+ return true;
+ }
+
+ const lastDot = firstSegment.lastIndexOf(".");
+ if (lastDot < 0) {
+ return true;
+ }
+
+ const extension = firstSegment.slice(lastDot + 1).toLowerCase();
+ return ASSISTANT_FILE_EXTENSIONS.has(extension);
+}
+
+function isDomainLikePathSegment(segment: string): boolean {
+ return /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(segment);
+}
+
+function resolveRelativePathUnderRoot(pathValue: string, workspaceRoot: string): string | null {
+ const normalizedPath = normalizePathToken(pathValue);
+ if (!normalizedPath || isAbsolutePath(normalizedPath)) {
+ return null;
+ }
+
+ const root = workspaceRoot.replace(/\/+$/, "") || "/";
+ const pathSegments = normalizedPath.split("/");
+ const resolvedSegments: string[] = [];
+ for (const segment of pathSegments) {
+ if (!segment || segment === ".") {
+ continue;
+ }
+ if (segment === "..") {
+ if (resolvedSegments.length === 0) {
+ return null;
+ }
+ resolvedSegments.pop();
+ continue;
+ }
+ resolvedSegments.push(segment);
+ }
+
+ if (resolvedSegments.length === 0) {
+ return root;
+ }
+
+ return root === "/" ? `/${resolvedSegments.join("/")}` : `${root}/${resolvedSegments.join("/")}`;
+}
+
function normalizeFileUrlPath(pathname: string): string | null {
if (!pathname) {
return null;
diff --git a/packages/server/src/utils/directory-suggestions.test.ts b/packages/server/src/utils/directory-suggestions.test.ts
index cef2928a0..2e62fe0a8 100644
--- a/packages/server/src/utils/directory-suggestions.test.ts
+++ b/packages/server/src/utils/directory-suggestions.test.ts
@@ -227,6 +227,47 @@ describe("searchWorkspaceEntries", () => {
expect(filesOnly).toEqual([{ path: "README.md", kind: "file" }]);
});
+ it("supports fuzzy basename queries for nested workspace files", async () => {
+ writeFileSync(path.join(workspaceDir, "src", "components", "message-renderer.tsx"), "");
+
+ const results = await searchWorkspaceEntries({
+ cwd: workspaceDir,
+ query: "msgrndr",
+ limit: 20,
+ includeFiles: true,
+ includeDirectories: false,
+ });
+
+ expect(results).toEqual([
+ {
+ path: "src/components/message-renderer.tsx",
+ kind: "file",
+ },
+ ]);
+ });
+
+ it("ranks fuzzy basename matches after exact, prefix, and substring matches", async () => {
+ writeFileSync(path.join(workspaceDir, "src", "components", "msgrndr"), "");
+ writeFileSync(path.join(workspaceDir, "src", "components", "msgrndr-panel.tsx"), "");
+ writeFileSync(path.join(workspaceDir, "src", "components", "use-msgrndr.ts"), "");
+ writeFileSync(path.join(workspaceDir, "src", "components", "message-renderer.tsx"), "");
+
+ const results = await searchWorkspaceEntries({
+ cwd: workspaceDir,
+ query: "msgrndr",
+ limit: 20,
+ includeFiles: true,
+ includeDirectories: false,
+ });
+
+ expect(results.map((entry) => entry.path)).toEqual([
+ "src/components/msgrndr",
+ "src/components/msgrndr-panel.tsx",
+ "src/components/use-msgrndr.ts",
+ "src/components/message-renderer.tsx",
+ ]);
+ });
+
// POSIX-only: creates and follows a symlink escape fixture.
it.skipIf(isWindows)(
"supports path-style queries and does not escape cwd through symlinks",
diff --git a/packages/server/src/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts
index f89279362..32f016679 100644
--- a/packages/server/src/utils/directory-suggestions.ts
+++ b/packages/server/src/utils/directory-suggestions.ts
@@ -73,6 +73,8 @@ const directoryListCache = new Map();
const workspaceEntryListCache = new Map();
const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER;
const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER;
+const NO_FUZZY_SCORE = Number.MAX_SAFE_INTEGER;
+const NO_WORKSPACE_MATCH_TIER = 5;
const WORKSPACE_IGNORED_DIRECTORY_NAMES = new Set([
"node_modules",
"dist",
@@ -127,6 +129,7 @@ interface RankedWorkspaceEntry {
matchTier: number;
segmentIndex: number;
matchOffset: number;
+ fuzzyScore: number;
depth: number;
}
@@ -303,18 +306,17 @@ async function searchWorkspaceWithinParentDirectory(input: {
if (entry.kind === "file" && !input.includeFiles) {
continue;
}
- if (searchLower && !entry.name.toLowerCase().includes(searchLower)) {
+ const rankedEntry = rankWorkspaceEntry({
+ absolutePath: entry.absolutePath,
+ kind: entry.kind,
+ workspaceRoot: input.workspaceRoot,
+ searchLower,
+ });
+ if (searchLower && rankedEntry.matchTier === NO_WORKSPACE_MATCH_TIER) {
continue;
}
- ranked.push(
- rankWorkspaceEntry({
- absolutePath: entry.absolutePath,
- kind: entry.kind,
- workspaceRoot: input.workspaceRoot,
- searchLower,
- }),
- );
+ ranked.push(rankedEntry);
}
return dedupeAndSortWorkspaceEntries(ranked).slice(0, input.limit);
@@ -374,23 +376,17 @@ async function searchWorkspaceAcrossTree(input: {
continue;
}
- const relativePath = normalizeRelativePath(input.workspaceRoot, entry.absolutePath);
- if (
- searchLower &&
- !relativePath.toLowerCase().includes(searchLower) &&
- !entry.name.toLowerCase().includes(searchLower)
- ) {
+ const rankedEntry = rankWorkspaceEntry({
+ absolutePath: entry.absolutePath,
+ kind: entry.kind,
+ workspaceRoot: input.workspaceRoot,
+ searchLower,
+ });
+ if (searchLower && rankedEntry.matchTier === NO_WORKSPACE_MATCH_TIER) {
continue;
}
- ranked.push(
- rankWorkspaceEntry({
- absolutePath: entry.absolutePath,
- kind: entry.kind,
- workspaceRoot: input.workspaceRoot,
- searchLower,
- }),
- );
+ ranked.push(rankedEntry);
}
}
@@ -430,6 +426,9 @@ function compareRankedWorkspaceEntries(
if (left.matchOffset !== right.matchOffset) {
return left.matchOffset - right.matchOffset;
}
+ if (left.fuzzyScore !== right.fuzzyScore) {
+ return left.fuzzyScore - right.fuzzyScore;
+ }
if (left.depth !== right.depth) {
return left.depth - right.depth;
}
@@ -538,6 +537,7 @@ function rankWorkspaceEntry(input: {
matchTier: 3,
segmentIndex: NO_SEGMENT_INDEX,
matchOffset: 0,
+ fuzzyScore: NO_FUZZY_SCORE,
depth,
};
}
@@ -551,7 +551,9 @@ function rankWorkspaceEntry(input: {
segment.includes(searchLower),
);
const matchOffset = relativeLower.indexOf(searchLower);
- let matchTier = 4;
+ const basename = segments.at(-1) ?? "";
+ const fuzzyScore = scoreFuzzySubsequence(searchLower, basename);
+ let matchTier = NO_WORKSPACE_MATCH_TIER;
let segmentIndex = NO_SEGMENT_INDEX;
if (exactSegmentIndex >= 0) {
@@ -565,6 +567,8 @@ function rankWorkspaceEntry(input: {
segmentIndex = partialSegmentIndex;
} else if (relativeLower.startsWith(searchLower)) {
matchTier = 3;
+ } else if (fuzzyScore !== null) {
+ matchTier = 4;
}
return {
@@ -573,10 +577,47 @@ function rankWorkspaceEntry(input: {
matchTier,
segmentIndex,
matchOffset: matchOffset >= 0 ? matchOffset : NO_MATCH_OFFSET,
+ fuzzyScore: fuzzyScore ?? NO_FUZZY_SCORE,
depth,
};
}
+function scoreFuzzySubsequence(query: string, candidate: string): number | null {
+ if (!query) {
+ return 0;
+ }
+
+ let queryIndex = 0;
+ let firstMatchIndex = -1;
+ let previousMatchIndex = -1;
+ let gapScore = 0;
+
+ for (
+ let candidateIndex = 0;
+ candidateIndex < candidate.length && queryIndex < query.length;
+ candidateIndex += 1
+ ) {
+ if (candidate[candidateIndex] !== query[queryIndex]) {
+ continue;
+ }
+
+ if (firstMatchIndex === -1) {
+ firstMatchIndex = candidateIndex;
+ }
+ if (previousMatchIndex >= 0) {
+ gapScore += candidateIndex - previousMatchIndex - 1;
+ }
+ previousMatchIndex = candidateIndex;
+ queryIndex += 1;
+ }
+
+ if (queryIndex !== query.length || firstMatchIndex === -1) {
+ return null;
+ }
+
+ return firstMatchIndex + gapScore;
+}
+
function findSegmentMatchIndex(
segments: string[],
predicate: (segment: string) => boolean,