diff --git a/packages/app/src/assistant-file-links/index.ts b/packages/app/src/assistant-file-links/index.ts index 7b1a93aeb..0a0e19207 100644 --- a/packages/app/src/assistant-file-links/index.ts +++ b/packages/app/src/assistant-file-links/index.ts @@ -1,6 +1,5 @@ export { AssistantInlineCodePathLink, - AssistantInlinePathLink, AssistantMarkdownCodeLink, AssistantMarkdownLink, } from "./link"; @@ -9,5 +8,9 @@ export { normalizeInlinePathTarget, type InlinePathTarget, } from "./parse"; +export { + AssistantFileLinkResolverProvider, + type AssistantFileLinkResolverProviderProps, +} from "./provider"; export type { AssistantFileLinkSource } from "./resolver"; -export { useAssistantFileLinkResolver } from "./use-resolver"; +export { useAssistantFileLinkActions } from "./use-file-link"; diff --git a/packages/app/src/assistant-file-links/link.tsx b/packages/app/src/assistant-file-links/link.tsx index 046ba15ea..b4395c353 100644 --- a/packages/app/src/assistant-file-links/link.tsx +++ b/packages/app/src/assistant-file-links/link.tsx @@ -1,11 +1,4 @@ -import { - useCallback, - useMemo, - useState, - type CSSProperties, - type ReactNode, - type MouseEvent, -} from "react"; +import { useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react"; import { Pressable, Text, @@ -16,128 +9,66 @@ import { } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { isNative, isWeb } from "@/constants/platform"; -import type { OpenFileDisposition } from "@/workspace/file-open"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { classifyAssistantFileLink, type InlinePathTarget } from "./parse"; +import { useStableEvent } from "@/hooks/use-stable-event"; +import { useAssistantFileLinkResolverContext } from "./provider"; import type { AssistantFileLinkSource } from "./resolver"; - -interface AssistantInlinePathLinkProps { - content: string; - parsed: InlinePathTarget; - onPress: (target: InlinePathTarget, disposition: OpenFileDisposition) => void; - workspaceRoot?: string; - style: StyleProp; -} - -export function AssistantInlinePathLink({ - content, - parsed, - onPress, - workspaceRoot, - style, -}: AssistantInlinePathLinkProps) { - const handlePress = useCallback(() => onPress(parsed, "main"), [onPress, parsed]); - const handleAnchorClickCapture = useCallback( - (event: MouseEvent) => { - event.preventDefault(); - if (!isModifiedOpenEvent(event)) { - return; - } - event.stopPropagation(); - onPress(parsed, "side"); - }, - [onPress, parsed], - ); - - if (!isNative) { - return ( - - - - {content} - - - - ); - } - - return ( - - {content} - - ); -} +import { useFileLink } from "./use-file-link"; interface AssistantMarkdownLinkProps { source: AssistantFileLinkSource; style: StyleProp; - onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void; - onPrefetch: (source: AssistantFileLinkSource) => void; - workspaceRoot?: string; children: ReactNode; } -export function AssistantMarkdownLink({ - source, - style, - onPress, - onPrefetch, - workspaceRoot, - children, -}: AssistantMarkdownLinkProps) { +export function AssistantMarkdownLink({ source, style, children }: AssistantMarkdownLinkProps) { const [hovered, setHovered] = useState(false); - const href = source.href; - const handlePress = useCallback(() => onPress(source, "main"), [onPress, source]); - const handleAnchorClickCapture = useCallback( - (event: MouseEvent) => { - event.preventDefault(); - if (!isModifiedOpenEvent(event)) { - return; - } - event.stopPropagation(); - onPress(source, "side"); - }, - [onPress, source], + const { target, onHoverIn, onPress, onAuxPress } = useFileLink(source); + const { configRef } = useAssistantFileLinkResolverContext(); + const workspaceRoot = configRef.current.workspaceRoot; + const tooltipPath = useMemo( + () => (target ? formatInlinePathTargetForTooltip(target, workspaceRoot) : null), + [target, workspaceRoot], ); - const handlePrefetch = useCallback(() => onPrefetch(source), [onPrefetch, source]); - const handleHoverIn = useCallback(() => { + const handleAnchorClickCapture = useStableEvent((event: MouseEvent) => { + event.preventDefault(); + if (!isModifiedOpenEvent(event)) { + return; + } + event.stopPropagation(); + onAuxPress(); + }); + const handleHoverIn = useStableEvent(() => { setHovered(true); - handlePrefetch(); - }, [handlePrefetch]); - const handleHoverOut = useCallback(() => setHovered(false), []); + onHoverIn(); + }); + const handleHoverOut = useStableEvent(() => setHovered(false)); const hoveredTextStyle = useMemo>( () => [style, hovered && { textDecorationLine: "underline" as const }], [style, hovered], ); - const tooltipFilePath = useMemo( - () => getMarkdownLinkTooltipFilePath(source.href, workspaceRoot), - [source.href, workspaceRoot], - ); + if (isNative) { return ( - - {children} - + + + {children} + + ); } const anchor = ( @@ -146,10 +77,7 @@ export function AssistantMarkdownLink({ ); - if (tooltipFilePath) { - return {anchor}; - } - return anchor; + return {anchor}; } interface AssistantMarkdownCodeLinkProps { @@ -157,9 +85,6 @@ interface AssistantMarkdownCodeLinkProps { inheritedStyles: TextStyle; codeInlineStyle: TextStyle; linkStyle: TextStyle; - onPress: (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => void; - onPrefetch: (source: AssistantFileLinkSource) => void; - workspaceRoot?: string; children: ReactNode; } @@ -168,9 +93,6 @@ export function AssistantMarkdownCodeLink({ inheritedStyles, codeInlineStyle, linkStyle, - onPress, - onPrefetch, - workspaceRoot, children, }: AssistantMarkdownCodeLinkProps) { const style = useMemo( @@ -178,74 +100,14 @@ export function AssistantMarkdownCodeLink({ [inheritedStyles, codeInlineStyle, linkStyle], ); return ( - + {children} ); } -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( - () => ({ - href: content, - text: content, - sourceType: "inline-code", - }), - [content], - ); - - return ( - - {content} - - ); -} - -function getMarkdownLinkTooltipFilePath( - href: string, - workspaceRoot: string | undefined, -): string | null { - const classification = classifyAssistantFileLink(href, { workspaceRoot }); - if (classification?.kind !== "directFile") { - return null; - } - return formatInlinePathTargetForTooltip(classification.target, workspaceRoot); -} - function formatInlinePathTargetForTooltip( - target: InlinePathTarget, + target: { path: string; lineStart?: number; lineEnd?: number }, workspaceRoot: string | undefined, ): string { let result = relativizePathToWorkspace(target.path, workspaceRoot); @@ -276,6 +138,40 @@ function relativizePathToWorkspace(filePath: string, workspaceRoot: string | und return filePath; } +interface AssistantInlineCodePathLinkProps { + content: string; + inheritedStyles: TextStyle; + codeInlineStyle: TextStyle; + linkStyle: TextStyle; +} + +export function AssistantInlineCodePathLink({ + content, + inheritedStyles, + codeInlineStyle, + linkStyle, +}: AssistantInlineCodePathLinkProps) { + const source = useMemo( + () => ({ + href: content, + text: content, + sourceType: "inline-code", + }), + [content], + ); + + return ( + + {content} + + ); +} + const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = { // RN doesn't type "inline-flex" but RN-web honors it at runtime, which keeps // the tooltip wrapper from breaking inline link flow. @@ -284,7 +180,13 @@ const FILE_LINK_TOOLTIP_TRIGGER_STYLE: ViewStyle = { const FILE_LINK_TOOLTIP_MOD_KEYS = ["mod"]; -function FileLinkHoverTooltip({ filePath, children }: { filePath: string; children: ReactNode }) { +function FileLinkHoverTooltip({ + filePath, + children, +}: { + filePath: string | null; + children: ReactNode; +}) { if (!isWeb) { return children; } @@ -293,19 +195,21 @@ function FileLinkHoverTooltip({ filePath, children }: { filePath: string; childr {children} - - - - {filePath} - - - - - click for side pane + {filePath ? ( + + + + {filePath} + + + + click for side pane + + - - + + ) : null} ); } diff --git a/packages/app/src/assistant-file-links/provider.tsx b/packages/app/src/assistant-file-links/provider.tsx new file mode 100644 index 000000000..77d0b2e03 --- /dev/null +++ b/packages/app/src/assistant-file-links/provider.tsx @@ -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; + getDirectorySuggestions: GetDirectorySuggestions; +} + +const AssistantFileLinkResolverContext = + createContext(null); + +export function AssistantFileLinkResolverProvider({ + client, + serverId, + workspaceRoot, + onOpenWorkspaceFile, + toast, + children, +}: AssistantFileLinkResolverProviderProps) { + const configRef = useRef({ + client, + serverId, + workspaceRoot, + onOpenWorkspaceFile, + toast, + }); + configRef.current = { client, serverId, workspaceRoot, onOpenWorkspaceFile, toast }; + + const getDirectorySuggestions = useCallback(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( + () => ({ configRef, getDirectorySuggestions }), + [getDirectorySuggestions], + ); + + return ( + + {children} + + ); +} + +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 }; diff --git a/packages/app/src/assistant-file-links/resolver.test.ts b/packages/app/src/assistant-file-links/resolver.test.ts index d085611e0..865e95e09 100644 --- a/packages/app/src/assistant-file-links/resolver.test.ts +++ b/packages/app/src/assistant-file-links/resolver.test.ts @@ -1,16 +1,15 @@ import { describe, expect, it, vi } from "vitest"; import { - createAssistantFileLinkResolver, + classifyForResolution, + fetchDaemonResolution, getAssistantFileLinkToken, + UnresolvedFileLinkError, type AssistantFileLinkContext, type DirectorySuggestionEntry, type DirectorySuggestionResult, } from "./resolver"; -import type { OpenFileDisposition } from "@/workspace/file-open"; -import type { InlinePathTarget } from "./parse"; const CONTEXT: AssistantFileLinkContext = { - serverId: "server-1", workspaceRoot: "/Users/test/project", }; @@ -20,554 +19,201 @@ function resolvedSuggestions( return { entries, error: null }; } -function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((promiseResolve, promiseReject) => { - resolve = promiseResolve; - reject = promiseReject; - }); - - return { promise, resolve, reject }; +function suggestionsFromMap(entriesByQuery: Record): { + getDirectorySuggestions: ReturnType; + searches: Array<{ + query: string; + cwd: string; + matchMode: "suffix"; + limit: number; + }>; +} { + const searches: Array<{ + query: string; + cwd: string; + matchMode: "suffix"; + limit: number; + }> = []; + const getDirectorySuggestions = vi.fn( + async (input: { + query: string; + cwd: string; + includeFiles: true; + includeDirectories: false; + matchMode: "suffix"; + limit: number; + }) => { + searches.push({ + query: input.query, + cwd: input.cwd, + matchMode: input.matchMode, + limit: input.limit, + }); + return resolvedSuggestions(entriesByQuery[input.query] ?? []); + }, + ); + return { getDirectorySuggestions, searches }; } -interface DirectorySearch { - query: string; - cwd: string; - includeFiles: true; - includeDirectories: false; - matchMode: "suffix"; - limit: number; -} +describe("classifyForResolution", () => { + it("returns the directFile target synchronously", () => { + const result = classifyForResolution({ href: "src/components/message.tsx#L33" }, CONTEXT); -interface OpenedFile { - target: InlinePathTarget; - disposition: OpenFileDisposition; -} - -class FakeWorkspaceFiles { - readonly searches: DirectorySearch[] = []; - readonly openedFiles: OpenedFile[] = []; - readonly unresolvedTokens: string[] = []; - - constructor(private readonly entriesByQuery: Record) {} - - createResolver() { - return createAssistantFileLinkResolver({ - getDirectorySuggestions: this.getDirectorySuggestions, - openWorkspaceFile: this.openWorkspaceFile, - openExternalUrl: async () => {}, - onUnresolvedFileCandidate: this.onUnresolvedFileCandidate, + expect(result).toEqual({ + kind: "resolved", + value: { + kind: "file", + target: { + raw: "src/components/message.tsx#L33", + path: "/Users/test/project/src/components/message.tsx", + lineStart: 33, + lineEnd: undefined, + }, + }, }); - } + }); - private getDirectorySuggestions = async ( - search: DirectorySearch, - ): Promise => { - this.searches.push(search); - return resolvedSuggestions(this.entriesByQuery[search.query] ?? []); - }; + it("preserves line ranges on direct workspace files", () => { + const result = classifyForResolution({ href: "src/components/message.tsx:33-40" }, CONTEXT); - private openWorkspaceFile = ( - target: InlinePathTarget, - disposition: OpenFileDisposition, - ): void => { - this.openedFiles.push({ target, disposition }); - }; + expect(result).toEqual({ + kind: "resolved", + value: { + kind: "file", + target: { + raw: "src/components/message.tsx:33-40", + path: "/Users/test/project/src/components/message.tsx", + lineStart: 33, + lineEnd: 40, + }, + }, + }); + }); - private onUnresolvedFileCandidate = (token: string): void => { - this.unresolvedTokens.push(token); - }; -} - -describe("assistant file link resolver", () => { - it("dedupes in-flight prefetches and serves the click from cache", async () => { - const suggestions = vi.fn(async () => - resolvedSuggestions([{ path: "src/dumm.md", kind: "file" }]), + it("flags basename inline-code as a daemon lookup keyed by suggestion query", () => { + const result = classifyForResolution( + { href: "file.ts:12", text: "file.ts:12", sourceType: "inline-code" }, + CONTEXT, ); - const openWorkspaceFile = vi.fn(); - const openExternalUrl = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: suggestions, - openWorkspaceFile, - openExternalUrl, - }); - const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" }; - await Promise.all([ - resolver.prefetch({ context: CONTEXT, source }), - resolver.prefetch({ context: CONTEXT, source }), - ]); - const result = await resolver.open({ context: CONTEXT, source, disposition: "main" }); - - expect(suggestions).toHaveBeenCalledTimes(1); - expect(suggestions).toHaveBeenCalledWith({ - query: "dumm.md", - cwd: "/Users/test/project", - includeFiles: true, - includeDirectories: false, - matchMode: "suffix", - limit: 1, - }); - expect(openWorkspaceFile).toHaveBeenCalledWith( - { - raw: "dumm.md", - path: "/Users/test/project/src/dumm.md", - lineStart: undefined, + expect(result).toEqual({ + kind: "needsLookup", + ambiguousQuery: "file.ts", + token: "file.ts:12", + target: { + raw: "file.ts:12", + path: "/Users/test/project/file.ts", + lineStart: 12, lineEnd: undefined, }, - "main", + }); + }); + + it("keeps explicit external URLs external", () => { + const result = classifyForResolution({ href: "http://dumm.md", text: "dumm.md" }, CONTEXT); + + expect(result).toEqual({ + kind: "resolved", + value: { kind: "external", url: "http://dumm.md" }, + }); + }); + + it("keeps auto-linkified normal domains external", () => { + const result = classifyForResolution( + { href: "http://google.com", text: "google.com", markup: "linkify" }, + CONTEXT, ); - expect(openExternalUrl).not.toHaveBeenCalled(); - expect(result.opened).toBe(true); + + expect(result).toEqual({ + kind: "resolved", + value: { kind: "external", url: "http://google.com" }, + }); }); - it("click consumes an in-flight hover resolution", async () => { - const deferred = createDeferred(); - const suggestions = vi.fn(() => deferred.promise); - const openWorkspaceFile = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: suggestions, - openWorkspaceFile, - openExternalUrl: vi.fn(), - }); - const source = { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" }; + it("returns ignored for non-file-looking content", () => { + const result = classifyForResolution({ href: "" }, CONTEXT); - const prefetch = resolver.prefetch({ context: CONTEXT, source }); - const opened = resolver.open({ context: CONTEXT, source, disposition: "main" }); - deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }])); - - await prefetch; - const result = await opened; - - expect(suggestions).toHaveBeenCalledTimes(1); - expect(openWorkspaceFile).toHaveBeenCalledWith( - { - raw: "dumm.md", - path: "/Users/test/project/docs/dumm.md", - lineStart: undefined, - lineEnd: undefined, - }, - "main", - ); - expect(result.opened).toBe(true); + expect(result).toEqual({ kind: "resolved", value: { kind: "ignored" } }); }); +}); - it("retries a click after hover prefetch fails to query suggestions", async () => { - const suggestions = vi - .fn() - .mockRejectedValueOnce(new Error("daemon unavailable")) - .mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }])); - const openWorkspaceFile = vi.fn(); - const openExternalUrl = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: suggestions, - openWorkspaceFile, - openExternalUrl, - }); - const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" }; - - const prefetchResult = await resolver.prefetch({ context: CONTEXT, source }); - const openResult = await resolver.open({ context: CONTEXT, source, disposition: "main" }); - - expect(prefetchResult).toEqual({ - kind: "unresolvedFileCandidate", - token: "dumm.md", - }); - expect(suggestions).toHaveBeenCalledTimes(2); - expect(openWorkspaceFile).toHaveBeenCalledWith( - { - raw: "dumm.md", - path: "/Users/test/project/docs/dumm.md", - lineStart: undefined, - lineEnd: undefined, - }, - "main", - ); - expect(openExternalUrl).not.toHaveBeenCalled(); - expect(openResult.opened).toBe(true); - }); - - it("does not cache unresolved candidates", async () => { - const suggestions = vi - .fn() - .mockResolvedValueOnce(resolvedSuggestions([])) - .mockResolvedValueOnce(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }])); - const openWorkspaceFile = vi.fn(); - const openExternalUrl = vi.fn(); - const onUnresolvedFileCandidate = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: suggestions, - openWorkspaceFile, - openExternalUrl, - onUnresolvedFileCandidate, - }); - const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" }; - - const first = await resolver.open({ context: CONTEXT, source, disposition: "main" }); - const second = await resolver.open({ context: CONTEXT, source, disposition: "main" }); - - expect(first).toEqual({ - kind: "unresolvedFileCandidate", - token: "dumm.md", - opened: false, - }); - expect(second.opened).toBe(true); - expect(suggestions).toHaveBeenCalledTimes(2); - expect(openWorkspaceFile).toHaveBeenCalledWith( - { - raw: "dumm.md", - path: "/Users/test/project/docs/dumm.md", - lineStart: undefined, - lineEnd: undefined, - }, - "main", - ); - expect(openExternalUrl).not.toHaveBeenCalled(); - expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1); - }); - - it("keys cache entries by server, workspace, and token", async () => { - const suggestions = vi - .fn() - .mockResolvedValueOnce(resolvedSuggestions([{ path: "one/dumm.md", kind: "file" }])) - .mockResolvedValueOnce(resolvedSuggestions([{ path: "two/dumm.md", kind: "file" }])); - const openWorkspaceFile = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: suggestions, - openWorkspaceFile, - openExternalUrl: vi.fn(), - }); - const source = { href: "http://dumm.md", text: "dumm.md", markup: "linkify" }; - - await resolver.open({ context: CONTEXT, source, disposition: "main" }); - await resolver.open({ - context: { serverId: "server-1", workspaceRoot: "/Users/test/other" }, - source, - disposition: "main", - }); - - expect(suggestions).toHaveBeenCalledTimes(2); - expect(openWorkspaceFile).toHaveBeenLastCalledWith( - { - raw: "dumm.md", - path: "/Users/test/other/two/dumm.md", - lineStart: undefined, - lineEnd: undefined, - }, - "main", - ); - }); - - it("does not apply stale async results after the active context changes", async () => { - const deferred = createDeferred(); - let isCurrent = true; - const openWorkspaceFile = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: vi.fn(() => deferred.promise), - openWorkspaceFile, - openExternalUrl: vi.fn(), - isCurrentContext: () => isCurrent, - }); - - const opened = resolver.open({ - context: CONTEXT, - source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" }, - disposition: "main", - }); - isCurrent = false; - deferred.resolve(resolvedSuggestions([{ path: "dumm.md", kind: "file" }])); - const result = await opened; - - expect(openWorkspaceFile).not.toHaveBeenCalled(); - expect(result.opened).toBe(false); - expect(result.kind).toBe("file"); - }); - - it("opens direct workspace file links without querying suggestions", async () => { - const suggestions = vi.fn(async () => resolvedSuggestions([])); - const openWorkspaceFile = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: suggestions, - openWorkspaceFile, - openExternalUrl: vi.fn(), - }); - - const result = await resolver.open({ - context: CONTEXT, - source: { href: "src/components/message.tsx#L33" }, - disposition: "main", - }); - - expect(suggestions).not.toHaveBeenCalled(); - expect(openWorkspaceFile).toHaveBeenCalledWith( - { - raw: "src/components/message.tsx#L33", - path: "/Users/test/project/src/components/message.tsx", - lineStart: 33, - lineEnd: undefined, - }, - "main", - ); - expect(result.opened).toBe(true); - }); - - it("preserves direct workspace file line ranges", async () => { - const openWorkspaceFile = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])), - openWorkspaceFile, - openExternalUrl: vi.fn(), - }); - - await resolver.open({ - context: CONTEXT, - source: { href: "src/components/message.tsx:33-40" }, - disposition: "main", - }); - - expect(openWorkspaceFile).toHaveBeenCalledWith( - { - raw: "src/components/message.tsx:33-40", - path: "/Users/test/project/src/components/message.tsx", - lineStart: 33, - lineEnd: 40, - }, - "main", - ); - }); - - it("opens basename line refs when the daemon returns that exact filename", async () => { - const workspaceFiles = new FakeWorkspaceFiles({ +describe("fetchDaemonResolution", () => { + it("resolves daemon suggestions into workspace file targets", async () => { + const { getDirectorySuggestions, searches } = suggestionsFromMap({ "file.ts": [{ path: "packages/app/src/file.ts", kind: "file" }], }); - const resolver = workspaceFiles.createResolver(); - const result = await resolver.open({ - context: { ...CONTEXT, workspaceRoot: "/Users/test/project" }, - source: { - href: "file.ts:12", - text: "file.ts:12", - sourceType: "inline-code", + const result = await fetchDaemonResolution({ + ambiguousQuery: "file.ts", + token: "file.ts:12", + target: { + raw: "file.ts:12", + path: "/Users/test/project/file.ts", + lineStart: 12, + lineEnd: undefined, }, - disposition: "main", + workspaceRoot: "/Users/test/project", + getDirectorySuggestions, }); - expect(workspaceFiles.searches).toEqual([ + expect(searches).toEqual([ { query: "file.ts", cwd: "/Users/test/project", - includeFiles: true, - includeDirectories: false, matchMode: "suffix", limit: 1, }, ]); - expect(workspaceFiles.openedFiles).toEqual([ - { + expect(result).toEqual({ + raw: "file.ts:12", + path: "/Users/test/project/packages/app/src/file.ts", + lineStart: 12, + lineEnd: undefined, + }); + }); + + it("throws a typed unresolved error when the daemon finds no match", async () => { + const { getDirectorySuggestions } = suggestionsFromMap({}); + + await expect( + fetchDaemonResolution({ + ambiguousQuery: "src/file.ts", + token: "src/file.ts", target: { - raw: "file.ts:12", - path: "/Users/test/project/packages/app/src/file.ts", - lineStart: 12, + raw: "src/file.ts", + path: "/Users/test/project/src/file.ts", + lineStart: undefined, lineEnd: undefined, }, - disposition: "main", - }, - ]); - expect(result.opened).toBe(true); - }); - - it("reports inline-code subpaths as unresolved when suffix suggestions find no file", async () => { - const workspaceFiles = new FakeWorkspaceFiles({}); - const resolver = workspaceFiles.createResolver(); - - const result = await resolver.open({ - context: { ...CONTEXT, workspaceRoot: "/Users/test/project" }, - source: { - href: "src/file.ts", - text: "src/file.ts", - sourceType: "inline-code", - }, - disposition: "main", - }); - - expect(workspaceFiles.searches).toEqual([ - { - query: "src/file.ts", - cwd: "/Users/test/project", - includeFiles: true, - includeDirectories: false, - matchMode: "suffix", - limit: 1, - }, - ]); - expect(workspaceFiles.openedFiles).toEqual([]); - expect(workspaceFiles.unresolvedTokens).toEqual(["src/file.ts"]); - expect(result).toEqual({ - kind: "unresolvedFileCandidate", - token: "src/file.ts", - opened: false, - }); - }); - - it("passes side open disposition to workspace file links", async () => { - const openWorkspaceFile = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])), - openWorkspaceFile, - openExternalUrl: vi.fn(), - }); - - await resolver.open({ - context: CONTEXT, - source: { href: "src/components/message.tsx#L33" }, - disposition: "side", - }); - - expect(openWorkspaceFile).toHaveBeenCalledWith( - { - raw: "src/components/message.tsx#L33", - path: "/Users/test/project/src/components/message.tsx", - lineStart: 33, - lineEnd: undefined, - }, - "side", - ); - }); - - it("keeps explicit external URLs external", async () => { - const openExternalUrl = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])), - openWorkspaceFile: vi.fn(), - openExternalUrl, - }); - - const result = await resolver.open({ - context: CONTEXT, - source: { href: "http://dumm.md", text: "dumm.md" }, - disposition: "main", - }); - - expect(openExternalUrl).toHaveBeenCalledWith("http://dumm.md"); - expect(result).toEqual({ - kind: "external", - url: "http://dumm.md", - opened: true, - }); - }); - - it("keeps auto-linkified normal domains external", async () => { - const suggestions = vi.fn(async () => resolvedSuggestions([])); - const openExternalUrl = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: suggestions, - openWorkspaceFile: vi.fn(), - openExternalUrl, - }); - - const result = await resolver.open({ - context: CONTEXT, - source: { href: "http://google.com", text: "google.com", markup: "linkify" }, - disposition: "main", - }); - - expect(suggestions).not.toHaveBeenCalled(); - expect(openExternalUrl).toHaveBeenCalledWith("http://google.com"); - expect(result).toEqual({ - kind: "external", - url: "http://google.com", - opened: true, - }); - }); - - it("keeps auto-linkified normal domain paths external", async () => { - const suggestions = vi.fn(async () => resolvedSuggestions([])); - const openExternalUrl = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: suggestions, - openWorkspaceFile: vi.fn(), - openExternalUrl, - }); - - const result = await resolver.open({ - context: CONTEXT, - source: { href: "http://openai.com/path", text: "openai.com/path", sourceInfo: "auto" }, - disposition: "main", - }); - - expect(suggestions).not.toHaveBeenCalled(); - expect(openExternalUrl).toHaveBeenCalledWith("http://openai.com/path"); - expect(result).toEqual({ - kind: "external", - url: "http://openai.com/path", - opened: true, - }); - }); - - it("does not open unresolved linkified markdown filenames in the browser", async () => { - const openWorkspaceFile = vi.fn(); - const openExternalUrl = vi.fn(); - const onUnresolvedFileCandidate = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: vi.fn(async () => resolvedSuggestions([])), - openWorkspaceFile, - openExternalUrl, - onUnresolvedFileCandidate, - }); - - const prefetchResult = await resolver.prefetch({ - context: CONTEXT, - source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" }, - }); - const result = await resolver.open({ - context: CONTEXT, - source: { href: "http://dumm.md", text: "dumm.md", sourceInfo: "auto" }, - disposition: "main", - }); - - expect(openWorkspaceFile).not.toHaveBeenCalled(); - expect(openExternalUrl).not.toHaveBeenCalled(); - expect(prefetchResult).toEqual({ - kind: "unresolvedFileCandidate", - token: "dumm.md", - }); - expect(onUnresolvedFileCandidate).toHaveBeenCalledTimes(1); - expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md"); - expect(result).toEqual({ - kind: "unresolvedFileCandidate", - token: "dumm.md", - opened: false, - }); - }); - - it("keeps failed ambiguous resolution out of the browser", async () => { - const openExternalUrl = vi.fn(); - const onUnresolvedFileCandidate = vi.fn(); - const resolver = createAssistantFileLinkResolver({ - getDirectorySuggestions: vi.fn(async () => { - throw new Error("daemon unavailable"); + workspaceRoot: "/Users/test/project", + getDirectorySuggestions, }), - openWorkspaceFile: vi.fn(), - openExternalUrl, - onUnresolvedFileCandidate, - }); - - const result = await resolver.open({ - context: CONTEXT, - source: { href: "http://dumm.md", text: "dumm.md", markup: "linkify" }, - disposition: "main", - }); - - expect(openExternalUrl).not.toHaveBeenCalled(); - expect(onUnresolvedFileCandidate).toHaveBeenCalledWith("dumm.md"); - expect(result).toEqual({ - kind: "unresolvedFileCandidate", - token: "dumm.md", - opened: false, - }); + ).rejects.toEqual(new UnresolvedFileLinkError("src/file.ts")); }); + it("throws a typed unresolved error when the daemon throws", async () => { + const getDirectorySuggestions = vi.fn(async () => { + throw new Error("daemon unavailable"); + }); + + await expect( + fetchDaemonResolution({ + ambiguousQuery: "dumm.md", + token: "dumm.md", + target: { + raw: "dumm.md", + path: "/Users/test/project/dumm.md", + lineStart: undefined, + lineEnd: undefined, + }, + workspaceRoot: "/Users/test/project", + getDirectorySuggestions, + }), + ).rejects.toEqual(new UnresolvedFileLinkError("dumm.md")); + }); +}); + +describe("getAssistantFileLinkToken", () => { it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => { expect( getAssistantFileLinkToken({ diff --git a/packages/app/src/assistant-file-links/resolver.ts b/packages/app/src/assistant-file-links/resolver.ts index f1310c642..a43fbb46e 100644 --- a/packages/app/src/assistant-file-links/resolver.ts +++ b/packages/app/src/assistant-file-links/resolver.ts @@ -4,12 +4,6 @@ import { type AssistantFileLinkClassification, type InlinePathTarget, } from "./parse"; -import type { OpenFileDisposition } from "@/workspace/file-open"; - -export interface AssistantFileLinkContext { - serverId?: string; - workspaceRoot?: string; -} export interface AssistantFileLinkSource { href: string; @@ -19,6 +13,10 @@ export interface AssistantFileLinkSource { sourceType?: "inline-code"; } +export interface AssistantFileLinkContext { + workspaceRoot?: string; +} + export interface DirectorySuggestionEntry { path: string; kind: "file" | "directory"; @@ -29,152 +27,121 @@ export interface DirectorySuggestionResult { error: string | null; } -export interface AssistantFileLinkResolverDependencies { - getDirectorySuggestions: (input: { - query: string; - cwd: string; - includeFiles: true; - includeDirectories: false; - matchMode: "suffix"; - limit: number; - }) => Promise; - openWorkspaceFile: (target: InlinePathTarget, disposition: OpenFileDisposition) => void; - openExternalUrl: (url: string) => void | Promise; - onUnresolvedFileCandidate?: (token: string) => void; - isCurrentContext?: (context: AssistantFileLinkContext) => boolean; -} - -export interface AssistantFileLinkResolver { - prefetch(input: AssistantFileLinkPrefetchInput): Promise; - open(input: AssistantFileLinkOpenInput): Promise; -} - -export interface AssistantFileLinkPrefetchInput { - context: AssistantFileLinkContext; - source: AssistantFileLinkSource; -} - -export interface AssistantFileLinkOpenInput extends AssistantFileLinkPrefetchInput { - disposition: OpenFileDisposition; -} +export type GetDirectorySuggestions = (input: { + query: string; + cwd: string; + includeFiles: true; + includeDirectories: false; + matchMode: "suffix"; + limit: number; +}) => Promise; export type ResolvedAssistantFileLink = + | { kind: "external"; url: string } + | { kind: "file"; target: InlinePathTarget } + | { kind: "ignored" }; + +export type AssistantFileLinkResolution = + | { kind: "resolved"; value: ResolvedAssistantFileLink } | { - kind: "external"; - url: string; - } - | { - kind: "file"; - target: InlinePathTarget; - } - | { - kind: "unresolvedFileCandidate"; + kind: "needsLookup"; + ambiguousQuery: string; token: string; - } - | { - kind: "ignored"; + target: InlinePathTarget; }; -export type AssistantFileLinkOpenResult = ResolvedAssistantFileLink & { - opened: boolean; -}; - -type CachedAssistantFileLink = Exclude; - -interface ParsedAssistantFileLinkInteraction { +export interface FetchDaemonResolutionInput { + ambiguousQuery: string; token: string; - classification: AssistantFileLinkClassification; + target: InlinePathTarget; + workspaceRoot?: string; + getDirectorySuggestions: GetDirectorySuggestions; } -export function createAssistantFileLinkResolver( - dependencies: AssistantFileLinkResolverDependencies, -): AssistantFileLinkResolver { - const cache = new Map(); - const inFlight = new Map>(); +export class UnresolvedFileLinkError extends Error { + constructor(readonly token: string) { + super(`No file found for ${token}`); + this.name = "UnresolvedFileLinkError"; + } +} - async function resolve( - input: AssistantFileLinkPrefetchInput, - ): Promise { - const parsed = parseInteraction(input); - if (!parsed) { - return { kind: "ignored" }; - } +export async function fetchDaemonResolution({ + ambiguousQuery, + token, + target, + workspaceRoot, + getDirectorySuggestions, +}: FetchDaemonResolutionInput): Promise { + const trimmedRoot = workspaceRoot?.trim(); + if (!trimmedRoot) { + throw new UnresolvedFileLinkError(token); + } - if (parsed.classification.kind === "external") { - return { kind: "external", url: parsed.classification.raw }; - } + let suggestions: DirectorySuggestionResult; + try { + suggestions = await getDirectorySuggestions({ + query: ambiguousQuery, + cwd: trimmedRoot, + includeFiles: true, + includeDirectories: false, + matchMode: "suffix", + limit: 1, + }); + } catch { + throw new UnresolvedFileLinkError(token); + } - if ( - parsed.classification.kind === "directFile" && - !shouldResolveDirectFileThroughSuggestions({ - context: input.context, - source: input.source, - token: parsed.token, - target: parsed.classification.target, - }) - ) { - return { kind: "file", target: parsed.classification.target }; - } - - const key = getResolutionKey(input.context, parsed.token); - const cached = cache.get(key); - if (cached) { - return cached; - } - - const active = inFlight.get(key); - if (active) { - return active; - } - - const request = resolveAmbiguousCandidate({ - context: input.context, - token: parsed.token, - target: parsed.classification.target, - getDirectorySuggestions: dependencies.getDirectorySuggestions, - }) - .then((result) => { - if (result.kind === "file") { - cache.set(key, result); - } - inFlight.delete(key); - return result; - }) - .catch((): CachedAssistantFileLink => { - inFlight.delete(key); - return { kind: "unresolvedFileCandidate", token: parsed.token }; - }); - - inFlight.set(key, request); - return request; + const match = suggestions.entries.find((entry) => entry.kind === "file"); + if (!match || suggestions.error) { + throw new UnresolvedFileLinkError(token); } return { - prefetch(input) { - return resolve(input); - }, - async open(input) { - const resolved = await resolve(input); - if (!canApplyResult(input.context, dependencies.isCurrentContext)) { - return { ...resolved, opened: false }; - } + ...target, + path: joinWorkspacePath(trimmedRoot, match.path), + }; +} - if (resolved.kind === "file") { - dependencies.openWorkspaceFile(resolved.target, input.disposition); - return { ...resolved, opened: true }; - } +export function classifyForResolution( + source: AssistantFileLinkSource, + context: AssistantFileLinkContext, +): AssistantFileLinkResolution { + const token = getAssistantFileLinkToken(source).trim(); + if (!token) { + return { kind: "resolved", value: { kind: "ignored" } }; + } - if (resolved.kind === "external") { - await dependencies.openExternalUrl(resolved.url); - return { ...resolved, opened: true }; - } + const classification = classifyAssistantFileLink(token, { + workspaceRoot: context.workspaceRoot, + }); + if (!classification) { + return { kind: "resolved", value: { kind: "ignored" } }; + } + if (classification.kind === "external") { + return { kind: "resolved", value: { kind: "external", url: classification.raw } }; + } + if ( + classification.kind === "directFile" && + !shouldResolveDirectFileThroughSuggestions({ + context, + source, + token, + target: classification.target, + }) + ) { + return { kind: "resolved", value: { kind: "file", target: classification.target } }; + } - if (resolved.kind === "unresolvedFileCandidate") { - dependencies.onUnresolvedFileCandidate?.(resolved.token); - } + const workspaceRoot = context.workspaceRoot?.trim(); + if (!workspaceRoot) { + return { kind: "resolved", value: { kind: "ignored" } }; + } - return { ...resolved, opened: false }; - }, + return { + kind: "needsLookup", + ambiguousQuery: getAmbiguousSuggestionQuery(classification.target, workspaceRoot), + token, + target: classification.target, }; } @@ -189,59 +156,10 @@ export function getAssistantFileLinkToken(source: AssistantFileLinkSource): stri return source.href; } -function parseInteraction( - input: AssistantFileLinkPrefetchInput, -): ParsedAssistantFileLinkInteraction | null { - const token = getAssistantFileLinkToken(input.source).trim(); - if (!token) { - return null; - } - - const classification = classifyAssistantFileLink(token, { - workspaceRoot: input.context.workspaceRoot, - }); - if (!classification) { - return null; - } - - return { token, classification }; -} - -async function resolveAmbiguousCandidate(input: { - context: AssistantFileLinkContext; - token: string; - target: InlinePathTarget; - getDirectorySuggestions: AssistantFileLinkResolverDependencies["getDirectorySuggestions"]; -}): Promise { - const workspaceRoot = input.context.workspaceRoot?.trim(); - if (!workspaceRoot) { - return { kind: "unresolvedFileCandidate", token: input.token }; - } - - const query = getAmbiguousSuggestionQuery(input.target, workspaceRoot); - const suggestions = await input.getDirectorySuggestions({ - query, - cwd: workspaceRoot, - includeFiles: true, - includeDirectories: false, - matchMode: "suffix", - limit: 1, - }); - const match = suggestions.entries.find((entry) => entry.kind === "file"); - if (!match || suggestions.error) { - return { kind: "unresolvedFileCandidate", token: input.token }; - } - - return { - kind: "file", - target: { - ...input.target, - path: joinWorkspacePath(workspaceRoot, match.path), - }, - }; -} - -function getAmbiguousSuggestionQuery(target: InlinePathTarget, workspaceRoot: string): string { +export function getAmbiguousSuggestionQuery( + target: InlinePathTarget, + workspaceRoot: string, +): string { const normalizedRoot = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, ""); const normalizedPath = target.path.replace(/\\/g, "/"); const prefix = `${normalizedRoot}/`; @@ -253,7 +171,7 @@ function getAmbiguousSuggestionQuery(target: InlinePathTarget, workspaceRoot: st return lastSlash >= 0 ? normalizedPath.slice(lastSlash + 1) : normalizedPath; } -function shouldResolveDirectFileThroughSuggestions(input: { +export function shouldResolveDirectFileThroughSuggestions(input: { context: AssistantFileLinkContext; source: AssistantFileLinkSource; token: string; @@ -285,23 +203,14 @@ function isAbsoluteInlineCodeToken(token: string): boolean { ); } -function getResolutionKey(context: AssistantFileLinkContext, token: string): string { - return [context.serverId ?? "", context.workspaceRoot ?? "", token].join("\0"); -} - function isLinkifiedSource(source: AssistantFileLinkSource): boolean { return source.markup === "linkify" || source.sourceInfo === "auto"; } -function canApplyResult( - context: AssistantFileLinkContext, - isCurrentContext: AssistantFileLinkResolverDependencies["isCurrentContext"], -): boolean { - return isCurrentContext ? isCurrentContext(context) : true; -} - function joinWorkspacePath(workspaceRoot: string, relativePath: string): string { const root = workspaceRoot.replace(/\\/g, "/").replace(/\/+$/, ""); const child = relativePath.replace(/\\/g, "/").replace(/^\/+/, ""); return root ? `${root}/${child}` : child; } + +export type { AssistantFileLinkClassification }; diff --git a/packages/app/src/assistant-file-links/use-file-link.test.tsx b/packages/app/src/assistant-file-links/use-file-link.test.tsx new file mode 100644 index 000000000..e35dff98e --- /dev/null +++ b/packages/app/src/assistant-file-links/use-file-link.test.tsx @@ -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() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((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; +} + +function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); +} + +function createWrapper(input: { + client: TestClient; + openedFiles: OpenedFile[]; + toast?: { + show: ReturnType; + copied: ReturnType; + error: ReturnType; + }; +}) { + const queryClient = createQueryClient(); + return function Wrapper({ children }: { children: ReactNode }) { + const openWorkspaceFile = useCallback( + (target: InlinePathTarget, disposition: OpenFileDisposition) => { + input.openedFiles.push({ target, disposition }); + }, + [], + ); + + return ( + + + {children} + + + ); + }; +} + +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, "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(); + 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(); + 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 ( + + + + {children} + + + + ); + } + + 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>(() => {}); diff --git a/packages/app/src/assistant-file-links/use-file-link.ts b/packages/app/src/assistant-file-links/use-file-link.ts new file mode 100644 index 000000000..9f129011d --- /dev/null +++ b/packages/app/src/assistant-file-links/use-file-link.ts @@ -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; +}): 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; + 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: "", +}; diff --git a/packages/app/src/assistant-file-links/use-resolver.ts b/packages/app/src/assistant-file-links/use-resolver.ts deleted file mode 100644 index c3facded4..000000000 --- a/packages/app/src/assistant-file-links/use-resolver.ts +++ /dev/null @@ -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): 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, 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], - ); -} diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 2826f8a8c..8db233236 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -74,7 +74,10 @@ import { type BottomAnchorLocalRequest, type BottomAnchorRouteRequest, } from "./use-bottom-anchor-controller"; -import { normalizeInlinePathTarget } from "@/assistant-file-links"; +import { + AssistantFileLinkResolverProvider, + normalizeInlinePathTarget, +} from "@/assistant-file-links"; import { createWorkspaceFileTabTarget, normalizeWorkspaceFileLocation, @@ -83,6 +86,7 @@ import { } from "@/workspace/file-open"; import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; +import { useStableEvent } from "@/hooks/use-stable-event"; import { isWeb } from "@/constants/platform"; import type { Theme } from "@/styles/theme"; @@ -322,7 +326,7 @@ const AgentStreamViewComponent = forwardRef { if (!target.path) { return; @@ -377,25 +381,11 @@ const AgentStreamViewComponent = forwardRef { - handleInlinePathPress({ raw: filePath, path: filePath }, "main"); - }, - [handleInlinePathPress], - ); + const handleToolCallOpenFile = useStableEvent((filePath: string) => { + handleInlinePathPress({ raw: filePath, path: filePath }, "main"); + }); const baseRenderModel = useMemo(() => { return buildAgentStreamRenderModel({ @@ -510,19 +500,25 @@ const AgentStreamViewComponent = forwardRef + > + + ); }, - [handleInlinePathPress, streamRenderStrategy, workspaceRoot, serverId, client, toast], + [client, handleInlinePathPress, resolvedServerId, streamRenderStrategy, toast, workspaceRoot], ); const renderThoughtItem = useCallback( diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 18015ad60..5cf4f99a0 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -68,9 +68,8 @@ import type { AgentAttachment } from "@server/shared/messages"; import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; import { buildToolCallPresentation } from "@/tool-calls/presentation"; import { resolveToolCallIcon } from "@/utils/tool-call-icon"; -import type { OpenFileDisposition } from "@/workspace/file-open"; import { getMarkdownListMarker, getMarkdownNextSiblingType } from "@/utils/markdown-list"; -import type { ToastApi } from "@/components/toast-host"; +import { useStableEvent } from "@/hooks/use-stable-event"; import { HighlightedCodeBlock } from "@/components/highlighted-code-block"; import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; import { formatDuration, formatMessageTimestamp } from "@/utils/time"; @@ -92,12 +91,11 @@ import { useToolCallSheet } from "./tool-call-sheet"; import { ToolCallDetailsContent } from "./tool-call-details"; import { AssistantInlineCodePathLink, - classifyAssistantFileLink, type AssistantFileLinkSource, AssistantMarkdownCodeLink, AssistantMarkdownLink, type InlinePathTarget, - useAssistantFileLinkResolver, + useAssistantFileLinkActions, } from "@/assistant-file-links"; import { getCompactionMarkerLabel } from "./message-compaction-label"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; @@ -717,11 +715,9 @@ export const LiveElapsed = memo(function LiveElapsed({ interface AssistantMessageProps { message: string; timestamp: number; - onInlinePathPress?: (target: InlinePathTarget, disposition: OpenFileDisposition) => void; workspaceRoot?: string; serverId?: string; client?: DaemonClient | null; - toast?: ToastApi | null; spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth"; } @@ -1563,11 +1559,9 @@ function MarkdownListView({ baseStyle, marginBottom, children }: MarkdownListVie export const AssistantMessage = memo(function AssistantMessage({ message, timestamp: _timestamp, - onInlinePathPress, workspaceRoot, serverId, client, - toast, spacing = "default", }: AssistantMessageProps) { const markdownParser = useMemo(() => { @@ -1583,36 +1577,14 @@ export const AssistantMessage = memo(function AssistantMessage({ return parser; }, []); - const fileLinkResolver = useAssistantFileLinkResolver({ - client, - serverId, - workspaceRoot, - onOpenWorkspaceFile: onInlinePathPress, - toast, + const fileLinkActions = useAssistantFileLinkActions(); + const handleMarkdownLinkPress = useStableEvent((url: string) => { + fileLinkActions.open({ href: url }, "main"); + // react-native-markdown-display opens the link itself when this returns true. + // We already handled it above, so return false to avoid duplicate opens. + return false; }); - const handleLinkPress = useCallback( - (source: AssistantFileLinkSource, disposition: OpenFileDisposition) => { - fileLinkResolver.open({ source, disposition }); - }, - [fileLinkResolver], - ); - const handleLinkPrefetch = useCallback( - (source: AssistantFileLinkSource) => { - fileLinkResolver.prefetch({ source }); - }, - [fileLinkResolver], - ); - const handleMarkdownLinkPress = useCallback( - (url: string) => { - fileLinkResolver.open({ source: { href: url }, disposition: "main" }); - // react-native-markdown-display opens the link itself when this returns true. - // We already handled it above, so return false to avoid duplicate opens. - return false; - }, - [fileLinkResolver], - ); - const markdownRules = useMemo(() => { return { text: ( @@ -1684,12 +1656,13 @@ export const AssistantMessage = memo(function AssistantMessage({ ) => { const content = node.content ?? ""; const isLinkedInlineCode = nodeHasParentType(parent, "link"); - const inlineCodeFileLink = classifyAssistantFileLink(content, { workspaceRoot }); + const inlineCodeSource: AssistantFileLinkSource = { + href: content, + text: content, + sourceType: "inline-code", + }; const shouldResolveInlinePath = - onInlinePathPress && - !isLinkedInlineCode && - inlineCodeFileLink && - inlineCodeFileLink.kind !== "external"; + !isLinkedInlineCode && fileLinkActions.canResolveFile(inlineCodeSource); if (shouldResolveInlinePath) { return ( @@ -1699,9 +1672,6 @@ export const AssistantMessage = memo(function AssistantMessage({ inheritedStyles={inheritedStyles} codeInlineStyle={styles.code_inline} linkStyle={styles.link} - onPress={handleLinkPress} - onPrefetch={handleLinkPrefetch} - workspaceRoot={workspaceRoot} /> ); } @@ -1719,9 +1689,6 @@ export const AssistantMessage = memo(function AssistantMessage({ inheritedStyles={inheritedStyles} codeInlineStyle={styles.code_inline} linkStyle={styles.link} - onPress={handleLinkPress} - onPrefetch={handleLinkPrefetch} - workspaceRoot={workspaceRoot} > {content} @@ -1800,9 +1767,6 @@ export const AssistantMessage = memo(function AssistantMessage({ key={node.key} source={getMarkdownLinkSource(node)} style={styles.link} - onPress={handleLinkPress} - onPrefetch={handleLinkPrefetch} - workspaceRoot={workspaceRoot} > {Children.map(children, (child) => { if (!isValidElement(child)) return child; @@ -1841,15 +1805,7 @@ export const AssistantMessage = memo(function AssistantMessage({ ); }, }; - }, [ - client, - handleLinkPrefetch, - handleLinkPress, - markdownParser, - onInlinePathPress, - serverId, - workspaceRoot, - ]); + }, [client, fileLinkActions, markdownParser, serverId, workspaceRoot]); const blocks = useMemo(() => splitMarkdownBlocks(message), [message]); const keyedBlocks = useMemo( diff --git a/packages/server/src/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts index 383f6d770..b889b8f08 100644 --- a/packages/server/src/utils/directory-suggestions.ts +++ b/packages/server/src/utils/directory-suggestions.ts @@ -32,8 +32,8 @@ export type WorkspaceMatchMode = "fuzzy" | "suffix"; const DEFAULT_LIMIT = 30; const MAX_LIMIT = 100; -const DEFAULT_MAX_DEPTH = 6; -const DEFAULT_MAX_DIRECTORIES_SCANNED = 5000; +const DEFAULT_MAX_DEPTH = 12; +const DEFAULT_MAX_DIRECTORIES_SCANNED = 20000; const DIRECTORY_LIST_CACHE_TTL_MS = 8_000; const DIRECTORY_LIST_CACHE_MAX_ENTRIES = 4_000;