From 2fd47abfa0f57f34e41fda625ca5ce465fd9d651 Mon Sep 17 00:00:00 2001 From: mikeohuo Date: Thu, 15 Oct 2026 19:37:16 -0400 Subject: [PATCH 1/7] fix(web): file chips open the file an agent meant when the root-joined path is missing Agents name files relative to the folder they cd'd into, but chips join every relative path onto the project root. When that literal file is missing, chip actions now look up the one indexed workspace file whose path ends with the same directories. An existing literal path always wins, and zero or several matches keep today's behavior. Preview, open in panel or editor, reveal, the built-in browser and both copy actions share the one resolution. Fixes #8251. --- apps/web/src/components/ChatMarkdown.tsx | 101 +++++++++------- apps/web/src/markdown-links.test.ts | 11 ++ apps/web/src/markdown-links.ts | 22 +++- apps/web/src/workspaceBasenameLookup.test.ts | 117 +++++++++++++++++++ apps/web/src/workspaceBasenameLookup.ts | 53 +++++++++ 5 files changed, 263 insertions(+), 41 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index dd0343ca990e..e684db21e108 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -171,6 +171,7 @@ import { rewriteMarkdownFileUriHref, shouldOpenMarkdownFileLinkInBrowserByDefault, shouldOpenMarkdownFileLinkInEditor, + withMarkdownFileLinkPath, type MarkdownFileLinkMeta, } from "../markdown-links"; import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; @@ -191,8 +192,7 @@ import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { projectEnvironment } from "../state/projects"; import { claimWorkspaceBasenameLookup, - needsWorkspaceBasenameLookup, - pickWorkspaceBasenameMatch, + findWorkspaceFileMatch, WORKSPACE_BASENAME_LOOKUP_LIMIT, } from "../workspaceBasenameLookup"; import { @@ -1202,7 +1202,6 @@ interface MarkdownFileLinkProps { href: string; targetPath: string; iconPath: string; - displayPath: string; /** What the files panel opens: workspace-relative inside the workspace, the absolute host path outside it, null when the panel cannot show the file. */ panelPath: string | null; @@ -1211,6 +1210,8 @@ interface MarkdownFileLinkProps { copyMarkdown: string; theme: "light" | "dark"; threadRef?: ScopedThreadRef | undefined; + /** The file the chip's actions target, which can differ from its literal path. */ + resolveLink: () => Promise>; onOpen?: ((targetPath: string) => Promise>) | undefined; onOpenInPanel: (panelPath: string, line: number | undefined) => void; openInEditorMenuLabel: string; @@ -1953,13 +1954,13 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ href, targetPath, iconPath, - displayPath, panelPath, line, label, copyMarkdown, theme, threadRef, + resolveLink, onOpen, onOpenInPanel, openInEditorMenuLabel, @@ -2170,11 +2171,11 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ return; } if (clicked === "copy-relative") { - handleCopy(displayPath, "Relative path"); + handleCopy((await resolveLink()).displayPath, "Relative path"); return; } if (clicked === "copy-full") { - handleCopy(targetPath, "Full path"); + handleCopy((await resolveLink()).targetPath, "Full path"); } } catch (cause) { reportMarkdownActionFailure( @@ -2184,7 +2185,6 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ } }, [ - displayPath, handleCopy, handleOpenInBrowser, handleOpenInEditor, @@ -2194,6 +2194,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ onOpen, onReveal, openInEditorMenuLabel, + resolveLink, revealLabel, targetPath, ], @@ -2304,13 +2305,13 @@ function areMarkdownFileLinkPropsEqual( previous.targetPath === next.targetPath && previous.iconPath === next.iconPath && previous.environmentId === next.environmentId && - previous.displayPath === next.displayPath && previous.panelPath === next.panelPath && previous.line === next.line && previous.label === next.label && previous.copyMarkdown === next.copyMarkdown && previous.theme === next.theme && previous.threadRef === next.threadRef && + previous.resolveLink === next.resolveLink && previous.onOpen === next.onOpen && previous.onOpenInPanel === next.onOpenInPanel && previous.openInEditorMenuLabel === next.openInEditorMenuLabel && @@ -2356,6 +2357,9 @@ function useChatMarkdownState({ const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, { reportFailure: false, }); + const readFileMetadata = useAtomQueryRunner(projectEnvironment.fileMetadata, { + reportFailure: false, + }); const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); @@ -2601,33 +2605,47 @@ function useChatMarkdownState({ }, [canOperatePreview, createAssetUrl, cwd, openPreview, preparedConnection, threadRef], ); - const findWorkspaceBasenameMatch = useCallback( + const findWorkspaceMatch = useCallback( async (workspaceRelativePath: string) => { if ( !cwd || environmentId === null || - !readEnvironmentScope(environmentId, AuthFilesystemReadScope) || - !needsWorkspaceBasenameLookup(workspaceRelativePath) + !readEnvironmentScope(environmentId, AuthFilesystemReadScope) ) { return null; } - const result = await searchProjectEntries({ - environmentId, - input: { - cwd, - query: workspaceRelativePath, - limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, - kind: "file", + return findWorkspaceFileMatch(workspaceRelativePath, { + literalExists: async () => { + const result = await readFileMetadata({ + environmentId, + input: { path: resolvePathLinkTarget(workspaceRelativePath, cwd) }, + }); + return result._tag !== "Success" || result.value !== null; + }, + searchFiles: async (query) => { + const result = await searchProjectEntries({ + environmentId, + input: { cwd, query, limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, kind: "file" }, + }); + return result._tag === "Success" ? result.value.entries : null; }, }); - return result._tag === "Success" - ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries) - : null; }, - [cwd, environmentId, searchProjectEntries], + [cwd, environmentId, readFileMetadata, searchProjectEntries], ); - // A bare filename resolves to the workspace root, which is rarely where the - // file is, so ask the index before opening. Absolute host paths open as-is. + // Every chip action opens the same file: the literal target, or the + // workspace file that findWorkspaceFileMatch picks in its place. + const resolveMarkdownFileLink = useCallback( + async (fileLinkMeta: MarkdownFileLinkMeta) => { + const workspaceRelativePath = fileLinkMeta.workspaceRelativePath; + const match = workspaceRelativePath ? await findWorkspaceMatch(workspaceRelativePath) : null; + return match && cwd + ? withMarkdownFileLinkPath(fileLinkMeta, resolvePathLinkTarget(match, cwd), cwd) + : fileLinkMeta; + }, + [cwd, findWorkspaceMatch], + ); + // Absolute host paths open as-is. const openFileInPanel = useCallback( (panelPath: string, line: number | undefined) => { if (!threadRef) return; @@ -2636,28 +2654,22 @@ function useChatMarkdownState({ const isLatestLookup = claimWorkspaceBasenameLookup(); const openAt = (path: string) => useRightPanelStore.getState().openFile(threadRef, path, line); - if (!cwd || !needsWorkspaceBasenameLookup(panelPath)) { + if (!cwd || isAbsolutePath(panelPath)) { openAt(panelPath); return; } void (async () => { - const match = await findWorkspaceBasenameMatch(panelPath); + const match = await findWorkspaceMatch(panelPath); if (!isLatestLookup()) return; openAt(match ?? panelPath); })(); }, - [cwd, findWorkspaceBasenameMatch, threadRef], + [cwd, findWorkspaceMatch, threadRef], ); const revealMarkdownFileInFileManager = useCallback( - async (fileLinkMeta: MarkdownFileLinkMeta) => { - const workspaceRelativePath = fileLinkMeta.workspaceRelativePath; - const match = workspaceRelativePath - ? await findWorkspaceBasenameMatch(workspaceRelativePath) - : null; - const filePath = match && cwd ? resolvePathLinkTarget(match, cwd) : fileLinkMeta.filePath; - return revealFileInFileManager(filePath); - }, - [cwd, findWorkspaceBasenameMatch, revealFileInFileManager], + async (fileLinkMeta: MarkdownFileLinkMeta) => + revealFileInFileManager((await resolveMarkdownFileLink(fileLinkMeta)).filePath), + [resolveMarkdownFileLink, revealFileInFileManager], ); const fileLinkChip = useCallback( (fileLinkMeta: MarkdownFileLinkMeta, copyMarkdown: string, mediaSource?: string) => { @@ -2671,6 +2683,7 @@ function useChatMarkdownState({ const panelPath = fileLinkMeta.workspaceRelativePath ?? (!canPreviewMedia && isAbsolutePath(fileLinkMeta.filePath) ? fileLinkMeta.filePath : null); + const resolveLink = () => resolveMarkdownFileLink(fileLinkMeta); return ( openInPreferredEditor((await resolveLink()).targetPath), + } + : {})} onOpenInPanel={openFileInPanel} onOpenMedia={ threadRef && canPreviewMedia - ? () => openMarkdownMedia(mediaPath, fileLinkMeta.filePath) + ? () => + void resolveLink().then((resolved) => + openMarkdownMedia(mediaPath, resolved.filePath), + ) : undefined } openInEditorMenuLabel={preferredEditorMenuLabel} @@ -2711,7 +2731,7 @@ function useChatMarkdownState({ canOperatePreview && isPreviewAvailableFor(threadRef.environmentId) && isBrowserPreviewFile(fileLinkMeta.filePath) - ? () => openMarkdownFileInPreview(fileLinkMeta.filePath) + ? async () => openMarkdownFileInPreview((await resolveLink()).filePath) : undefined } /> @@ -2727,6 +2747,7 @@ function useChatMarkdownState({ openMarkdownFileInPreview, openMarkdownMedia, preferredEditorMenuLabel, + resolveMarkdownFileLink, resolvedTheme, revealInFileManagerLabel, revealMarkdownFileInFileManager, diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 3aaed9058c01..09d462e0edeb 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -6,6 +6,7 @@ import { rewriteMarkdownFileUriHref, shouldOpenMarkdownFileLinkInBrowserByDefault, shouldOpenMarkdownFileLinkInEditor, + withMarkdownFileLinkPath, } from "./markdown-links"; describe("shouldOpenMarkdownFileLinkInEditor", () => { @@ -267,6 +268,16 @@ describe("resolveInlineCodeFileLinkMeta", () => { }); }); +describe("withMarkdownFileLinkPath", () => { + it("points every path field at the new file and keeps the position", () => { + const meta = resolveInlineCodeFileLinkMeta("docs/plan.md:12:3", "/repo"); + expect(meta).not.toBeNull(); + expect(withMarkdownFileLinkPath(meta!, "/repo/BudgetLens/docs/plan.md", "/repo")).toEqual( + resolveMarkdownFileLinkMeta("/repo/BudgetLens/docs/plan.md:12:3", "/repo"), + ); + }); +}); + describe("directory paths with a trailing separator", () => { it("keeps the final segment for a POSIX directory path", () => { expect(resolveMarkdownFileLinkMeta("/tmp/favicons/", "/repo/project")).toMatchObject({ diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index 3aa188d27ec2..0c647bb52d98 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -5,7 +5,11 @@ import { normalizeMarkdownLinkDestination, resolveMarkdownFileLinkTarget, } from "@t3tools/shared/markdownLinks"; -import { parseFileUrlHref, splitFilePathPosition } from "@t3tools/shared/fileLinks"; +import { + formatFilePathPosition, + parseFileUrlHref, + splitFilePathPosition, +} from "@t3tools/shared/fileLinks"; import { formatWorkspaceRelativePath } from "./filePathDisplay"; import { isTerminalLinkActivation } from "./terminal-links"; @@ -84,6 +88,22 @@ export function resolveMarkdownFileLinkMeta( return buildFileLinkMetaFromTarget(targetPath, cwd); } +/** The same link pointed at another file, keeping its line and column. */ +export function withMarkdownFileLinkPath( + meta: MarkdownFileLinkMeta, + filePath: string, + cwd?: string, +): MarkdownFileLinkMeta { + return buildFileLinkMetaFromTarget( + formatFilePathPosition({ + path: filePath, + ...(meta.line !== undefined ? { line: meta.line } : {}), + ...(meta.column !== undefined ? { column: meta.column } : {}), + }), + cwd, + ); +} + function buildFileLinkMetaFromTarget(targetPath: string, cwd?: string): MarkdownFileLinkMeta { const { path, line, column } = splitFilePathPosition(targetPath); return { diff --git a/apps/web/src/workspaceBasenameLookup.test.ts b/apps/web/src/workspaceBasenameLookup.test.ts index afe159dd402b..13b2cfda548d 100644 --- a/apps/web/src/workspaceBasenameLookup.test.ts +++ b/apps/web/src/workspaceBasenameLookup.test.ts @@ -2,8 +2,11 @@ import { describe, expect, it } from "vite-plus/test"; import { claimWorkspaceBasenameLookup, + findWorkspaceFileMatch, needsWorkspaceBasenameLookup, pickWorkspaceBasenameMatch, + pickWorkspaceSuffixMatch, + type WorkspaceEntryCandidate, } from "./workspaceBasenameLookup"; describe("needsWorkspaceBasenameLookup", () => { @@ -76,6 +79,120 @@ describe("pickWorkspaceBasenameMatch", () => { }); }); +describe("pickWorkspaceSuffixMatch", () => { + it("finds the file a relative path names inside a subfolder", () => { + expect( + pickWorkspaceSuffixMatch("docs/GROUPS_PLAN.md", [ + { path: "BudgetLens/docs/GROUPS_PLAN.md", kind: "file" }, + { path: "BudgetLens/docs/other.md", kind: "file" }, + ]), + ).toBe("BudgetLens/docs/GROUPS_PLAN.md"); + }); + + it("matches whole segments only", () => { + expect( + pickWorkspaceSuffixMatch("docs/plan.md", [{ path: "src/mydocs/plan.md", kind: "file" }]), + ).toBeNull(); + }); + + it("accepts Windows separators on either side", () => { + expect( + pickWorkspaceSuffixMatch("lab\\out\\icon.png", [ + { path: "Docs/notes/lab/out/icon.png", kind: "file" }, + ]), + ).toBe("Docs/notes/lab/out/icon.png"); + }); + + it("resolves to nothing when two files share the suffix", () => { + expect( + pickWorkspaceSuffixMatch("docs/plan.md", [ + { path: "a/docs/plan.md", kind: "file" }, + { path: "b/docs/plan.md", kind: "file" }, + ]), + ).toBeNull(); + }); + + it("folds case only when no exact match exists", () => { + expect( + pickWorkspaceSuffixMatch("Docs/Plan.md", [{ path: "a/docs/plan.md", kind: "file" }]), + ).toBe("a/docs/plan.md"); + expect( + pickWorkspaceSuffixMatch("docs/plan.md", [ + { path: "a/docs/plan.md", kind: "file" }, + { path: "b/Docs/Plan.md", kind: "file" }, + ]), + ).toBe("a/docs/plan.md"); + }); + + it("ignores directories", () => { + expect( + pickWorkspaceSuffixMatch("src/components", [ + { path: "apps/src/components", kind: "directory" }, + ]), + ).toBeNull(); + }); +}); + +describe("findWorkspaceFileMatch", () => { + const nestedPlan: WorkspaceEntryCandidate = { + path: "BudgetLens/docs/GROUPS_PLAN.md", + kind: "file", + }; + const lookup = ( + literalExists: boolean, + entries: ReadonlyArray | null, + ) => { + const queries: string[] = []; + return { + queries, + literalExists: async () => literalExists, + searchFiles: async (query: string) => { + queries.push(query); + return entries; + }, + }; + }; + + it("keeps an existing literal path even when the index holds only a nested twin", async () => { + // The literal root file can be gitignored, so the index cannot vouch for it. + const deps = lookup(true, [nestedPlan]); + await expect(findWorkspaceFileMatch("docs/GROUPS_PLAN.md", deps)).resolves.toBeNull(); + expect(deps.queries).toEqual([]); + }); + + it("opens the unique nested match when the literal path is missing", async () => { + await expect( + findWorkspaceFileMatch("docs/GROUPS_PLAN.md", lookup(false, [nestedPlan])), + ).resolves.toBe("BudgetLens/docs/GROUPS_PLAN.md"); + }); + + it("keeps the literal path when the missing file has no unique match", async () => { + await expect( + findWorkspaceFileMatch( + "docs/GROUPS_PLAN.md", + lookup(false, [nestedPlan, { path: "Other/docs/GROUPS_PLAN.md", kind: "file" }]), + ), + ).resolves.toBeNull(); + await expect( + findWorkspaceFileMatch("docs/GROUPS_PLAN.md", lookup(false, null)), + ).resolves.toBeNull(); + }); + + it("still sends a bare name straight to the index", async () => { + const deps = lookup(true, [{ path: "apps/web/src/ChatView.tsx", kind: "file" }]); + await expect(findWorkspaceFileMatch("ChatView.tsx", deps)).resolves.toBe( + "apps/web/src/ChatView.tsx", + ); + expect(deps.queries).toEqual(["ChatView.tsx"]); + }); + + it("leaves workspace-root links alone", async () => { + const deps = lookup(false, [nestedPlan]); + await expect(findWorkspaceFileMatch(".", deps)).resolves.toBeNull(); + expect(deps.queries).toEqual([]); + }); +}); + describe("claimWorkspaceBasenameLookup", () => { it("keeps only the newest claim, whatever order the lookups settle in", () => { const first = claimWorkspaceBasenameLookup(); diff --git a/apps/web/src/workspaceBasenameLookup.ts b/apps/web/src/workspaceBasenameLookup.ts index 0efdfcf3a44d..2d6fd55cdb0c 100644 --- a/apps/web/src/workspaceBasenameLookup.ts +++ b/apps/web/src/workspaceBasenameLookup.ts @@ -52,3 +52,56 @@ export function pickWorkspaceBasenameMatch( ); return foldedMatches.length === 1 ? (foldedMatches[0]?.path ?? null) : null; } + +function slashPath(path: string): string { + return path.trim().replaceAll("\\", "/"); +} + +/** + * The one indexed file whose path ends with every segment of `relativePath`, + * e.g. `docs/plan.md` in `BudgetLens/docs/plan.md`. Two candidates have no + * right answer, so they resolve to nothing. + */ +export function pickWorkspaceSuffixMatch( + relativePath: string, + entries: ReadonlyArray, +): string | null { + const target = slashPath(relativePath); + if (!target) return null; + const files = entries.filter((entry) => entry.kind === "file"); + const matching = (fold: (path: string) => string) => { + const suffix = fold(target); + return files.filter((entry) => { + const path = fold(slashPath(entry.path)); + return path === suffix || path.endsWith(`/${suffix}`); + }); + }; + const exact = matching((path) => path); + const matches = exact.length > 0 ? exact : matching((path) => path.toLowerCase()); + return matches.length === 1 ? (matches[0]?.path ?? null) : null; +} + +/** + * Which workspace file a chip's workspace-relative path opens, or null to keep + * the literal path. Agents name files relative to the folder they work in, so + * a bare name asks the index, and a path with directories asks it only when + * the literal file is missing: the index skips ignored files, so its silence + * proves nothing about the literal path. + */ +export async function findWorkspaceFileMatch( + relativePath: string, + lookup: { + /** False only when the literal file is known to be missing. */ + readonly literalExists: () => Promise; + /** Indexed candidates for the query, or null when the search failed. */ + readonly searchFiles: (query: string) => Promise | null>; + }, +): Promise { + if (needsWorkspaceBasenameLookup(relativePath)) { + const entries = await lookup.searchFiles(relativePath); + return entries ? pickWorkspaceBasenameMatch(relativePath, entries) : null; + } + if (!/[\\/]/.test(relativePath) || (await lookup.literalExists())) return null; + const entries = await lookup.searchFiles(relativePath); + return entries ? pickWorkspaceSuffixMatch(relativePath, entries) : null; +} From 943287bcb341dac7b986bc0c600765cf10d6950b Mon Sep 17 00:00:00 2001 From: mikeohuo Date: Thu, 15 Oct 2026 19:40:49 -0400 Subject: [PATCH 2/7] fix(web): a stale chip media lookup no longer replaces a newer preview The media request is now claimed at click time, before the file lookup, so a later click or a thread switch during the lookup drops the older open. --- apps/web/src/components/ChatMarkdown.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e684db21e108..1e6429ab0ea6 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -2713,10 +2713,15 @@ function useChatMarkdownState({ onOpenInPanel={openFileInPanel} onOpenMedia={ threadRef && canPreviewMedia - ? () => - void resolveLink().then((resolved) => - openMarkdownMedia(mediaPath, resolved.filePath), - ) + ? () => { + // Claim the request before the lookup, so a later click or a + // thread switch during it drops this open. + const requestId = ++mediaRequestId.current; + void resolveLink().then((resolved) => { + if (mediaRequestId.current !== requestId) return; + openMarkdownMedia(mediaPath, resolved.filePath); + }); + } : undefined } openInEditorMenuLabel={preferredEditorMenuLabel} From e6ec1d9bf40a15d6fc15cb45cd5d454b4754cd07 Mon Sep 17 00:00:00 2001 From: mikeohuo Date: Thu, 15 Oct 2026 20:17:05 -0400 Subject: [PATCH 3/7] fix(web): chip path copies start inside the click, and truncated searches cannot pick a hidden twin Copy relative path and Copy full path now start the clipboard write within the menu click, with a promise-valued ClipboardItem, while the path resolves. The workspace index ranks whole-suffix matches ahead of fuzzy ones, so a truncated result page can hide a second match only when its last file is a match. The suffix pick now resolves to nothing in that case. Rejecting every truncated page would disable the fallback, because ordinary nested paths also match many files fuzzily. --- apps/web/src/components/ChatMarkdown.tsx | 32 +++++++++++++++----- apps/web/src/workspaceBasenameLookup.test.ts | 22 +++++++++++++- apps/web/src/workspaceBasenameLookup.ts | 22 +++++++++----- 3 files changed, 61 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 1e6429ab0ea6..377a5abb46e0 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -2096,7 +2096,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }, [onReveal, targetPath]); const handleCopy = useCallback( - (value: string, title: string) => { + (value: string | Promise, title: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { toastManager.add( stackedThreadToast({ @@ -2108,12 +2108,24 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ return; } - void navigator.clipboard.writeText(value).then( - () => { + // Start a pending value's write within the menu click, so the user + // gesture still counts when the path finishes resolving. + const write = + typeof value === "string" + ? navigator.clipboard.writeText(value) + : typeof ClipboardItem !== "undefined" && navigator.clipboard.write + ? navigator.clipboard.write([ + new ClipboardItem({ + "text/plain": value.then((text) => new Blob([text], { type: "text/plain" })), + }), + ]) + : value.then((text) => navigator.clipboard.writeText(text)); + void Promise.all([value, write]).then( + ([copied]) => { toastManager.add({ type: "success", title: `${title} copied`, - description: value, + description: copied, }); }, (error) => { @@ -2171,11 +2183,17 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ return; } if (clicked === "copy-relative") { - handleCopy((await resolveLink()).displayPath, "Relative path"); + handleCopy( + resolveLink().then((link) => link.displayPath), + "Relative path", + ); return; } if (clicked === "copy-full") { - handleCopy((await resolveLink()).targetPath, "Full path"); + handleCopy( + resolveLink().then((link) => link.targetPath), + "Full path", + ); } } catch (cause) { reportMarkdownActionFailure( @@ -2627,7 +2645,7 @@ function useChatMarkdownState({ environmentId, input: { cwd, query, limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, kind: "file" }, }); - return result._tag === "Success" ? result.value.entries : null; + return result._tag === "Success" ? result.value : null; }, }); }, diff --git a/apps/web/src/workspaceBasenameLookup.test.ts b/apps/web/src/workspaceBasenameLookup.test.ts index 13b2cfda548d..179938132a17 100644 --- a/apps/web/src/workspaceBasenameLookup.test.ts +++ b/apps/web/src/workspaceBasenameLookup.test.ts @@ -124,6 +124,15 @@ describe("pickWorkspaceSuffixMatch", () => { ).toBe("a/docs/plan.md"); }); + it("trusts a truncated page only when a fuzzy file follows the match", () => { + const match: WorkspaceEntryCandidate = { path: "apps/web/src/ChatMarkdown.tsx", kind: "file" }; + const fuzzy: WorkspaceEntryCandidate = { path: "apps/web/src/ChatMarkup.tsx", kind: "file" }; + expect(pickWorkspaceSuffixMatch("src/ChatMarkdown.tsx", [match, fuzzy], true)).toBe(match.path); + // The page ends on the match, so the next page could hold another one. + expect(pickWorkspaceSuffixMatch("src/ChatMarkdown.tsx", [fuzzy, match], true)).toBeNull(); + expect(pickWorkspaceSuffixMatch("src/ChatMarkdown.tsx", [fuzzy, match])).toBe(match.path); + }); + it("ignores directories", () => { expect( pickWorkspaceSuffixMatch("src/components", [ @@ -141,6 +150,7 @@ describe("findWorkspaceFileMatch", () => { const lookup = ( literalExists: boolean, entries: ReadonlyArray | null, + truncated = false, ) => { const queries: string[] = []; return { @@ -148,7 +158,7 @@ describe("findWorkspaceFileMatch", () => { literalExists: async () => literalExists, searchFiles: async (query: string) => { queries.push(query); - return entries; + return entries ? { entries, truncated } : null; }, }; }; @@ -178,6 +188,16 @@ describe("findWorkspaceFileMatch", () => { ).resolves.toBeNull(); }); + it("passes a truncated page through to the suffix pick", async () => { + const fuzzy: WorkspaceEntryCandidate = { path: "docs/GROUPS_PLAN.old.md", kind: "file" }; + await expect( + findWorkspaceFileMatch("docs/GROUPS_PLAN.md", lookup(false, [nestedPlan, fuzzy], true)), + ).resolves.toBe("BudgetLens/docs/GROUPS_PLAN.md"); + await expect( + findWorkspaceFileMatch("docs/GROUPS_PLAN.md", lookup(false, [fuzzy, nestedPlan], true)), + ).resolves.toBeNull(); + }); + it("still sends a bare name straight to the index", async () => { const deps = lookup(true, [{ path: "apps/web/src/ChatView.tsx", kind: "file" }]); await expect(findWorkspaceFileMatch("ChatView.tsx", deps)).resolves.toBe( diff --git a/apps/web/src/workspaceBasenameLookup.ts b/apps/web/src/workspaceBasenameLookup.ts index 2d6fd55cdb0c..cf06cf3144c7 100644 --- a/apps/web/src/workspaceBasenameLookup.ts +++ b/apps/web/src/workspaceBasenameLookup.ts @@ -60,11 +60,14 @@ function slashPath(path: string): string { /** * The one indexed file whose path ends with every segment of `relativePath`, * e.g. `docs/plan.md` in `BudgetLens/docs/plan.md`. Two candidates have no - * right answer, so they resolve to nothing. + * right answer, so they resolve to nothing. The index ranks whole-suffix + * matches ahead of fuzzy ones, so a truncated page can hide another match + * only when its last file is a match too. */ export function pickWorkspaceSuffixMatch( relativePath: string, entries: ReadonlyArray, + truncated = false, ): string | null { const target = slashPath(relativePath); if (!target) return null; @@ -78,7 +81,9 @@ export function pickWorkspaceSuffixMatch( }; const exact = matching((path) => path); const matches = exact.length > 0 ? exact : matching((path) => path.toLowerCase()); - return matches.length === 1 ? (matches[0]?.path ?? null) : null; + const match = matches.length === 1 ? matches[0] : undefined; + if (!match || (truncated && files.at(-1) === match)) return null; + return match.path; } /** @@ -94,14 +99,17 @@ export async function findWorkspaceFileMatch( /** False only when the literal file is known to be missing. */ readonly literalExists: () => Promise; /** Indexed candidates for the query, or null when the search failed. */ - readonly searchFiles: (query: string) => Promise | null>; + readonly searchFiles: (query: string) => Promise<{ + readonly entries: ReadonlyArray; + readonly truncated: boolean; + } | null>; }, ): Promise { if (needsWorkspaceBasenameLookup(relativePath)) { - const entries = await lookup.searchFiles(relativePath); - return entries ? pickWorkspaceBasenameMatch(relativePath, entries) : null; + const result = await lookup.searchFiles(relativePath); + return result ? pickWorkspaceBasenameMatch(relativePath, result.entries) : null; } if (!/[\\/]/.test(relativePath) || (await lookup.literalExists())) return null; - const entries = await lookup.searchFiles(relativePath); - return entries ? pickWorkspaceSuffixMatch(relativePath, entries) : null; + const result = await lookup.searchFiles(relativePath); + return result ? pickWorkspaceSuffixMatch(relativePath, result.entries, result.truncated) : null; } From 5799d8082b81dde9391ea66475bb58c32a84b1af Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 20:34:25 +0000 Subject: [PATCH 4/7] test(web): trim file chip fallback coverage and reject truncated searches --- apps/web/src/markdown-links.test.ts | 16 +- apps/web/src/workspaceBasenameLookup.test.ts | 162 +++++++------------ 2 files changed, 70 insertions(+), 108 deletions(-) diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 09d462e0edeb..2bfb489c7453 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vite-plus/test"; +import { assert, describe, expect, it } from "vite-plus/test"; import { resolveInlineCodeFileLinkMeta, @@ -271,10 +271,16 @@ describe("resolveInlineCodeFileLinkMeta", () => { describe("withMarkdownFileLinkPath", () => { it("points every path field at the new file and keeps the position", () => { const meta = resolveInlineCodeFileLinkMeta("docs/plan.md:12:3", "/repo"); - expect(meta).not.toBeNull(); - expect(withMarkdownFileLinkPath(meta!, "/repo/BudgetLens/docs/plan.md", "/repo")).toEqual( - resolveMarkdownFileLinkMeta("/repo/BudgetLens/docs/plan.md:12:3", "/repo"), - ); + assert(meta); + expect(withMarkdownFileLinkPath(meta, "/repo/BudgetLens/docs/plan.md", "/repo")).toEqual({ + filePath: "/repo/BudgetLens/docs/plan.md", + targetPath: "/repo/BudgetLens/docs/plan.md:12:3", + displayPath: "repo/BudgetLens/docs/plan.md:12:3", + workspaceRelativePath: "BudgetLens/docs/plan.md", + basename: "plan.md", + line: 12, + column: 3, + }); }); }); diff --git a/apps/web/src/workspaceBasenameLookup.test.ts b/apps/web/src/workspaceBasenameLookup.test.ts index c2ac4020dba5..d204827c6086 100644 --- a/apps/web/src/workspaceBasenameLookup.test.ts +++ b/apps/web/src/workspaceBasenameLookup.test.ts @@ -1,11 +1,10 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { claimWorkspaceBasenameLookup, findWorkspaceFileMatch, needsWorkspaceBasenameLookup, pickWorkspaceBasenameMatch, - pickWorkspaceSuffixMatch, type WorkspaceEntryCandidate, } from "./workspaceBasenameLookup"; @@ -79,137 +78,94 @@ describe("pickWorkspaceBasenameMatch", () => { }); }); -describe("pickWorkspaceSuffixMatch", () => { - it("finds the file a relative path names inside a subfolder", () => { - expect( - pickWorkspaceSuffixMatch("docs/GROUPS_PLAN.md", [ - { path: "BudgetLens/docs/GROUPS_PLAN.md", kind: "file" }, - { path: "BudgetLens/docs/other.md", kind: "file" }, - ]), - ).toBe("BudgetLens/docs/GROUPS_PLAN.md"); - }); - - it("matches whole segments only", () => { - expect( - pickWorkspaceSuffixMatch("docs/plan.md", [{ path: "src/mydocs/plan.md", kind: "file" }]), - ).toBeNull(); - }); - - it("accepts Windows separators on either side", () => { - expect( - pickWorkspaceSuffixMatch("lab\\out\\icon.png", [ - { path: "Docs/notes/lab/out/icon.png", kind: "file" }, - ]), - ).toBe("Docs/notes/lab/out/icon.png"); - }); - - it("resolves to nothing when two files share the suffix", () => { - expect( - pickWorkspaceSuffixMatch("docs/plan.md", [ - { path: "a/docs/plan.md", kind: "file" }, - { path: "b/docs/plan.md", kind: "file" }, - ]), - ).toBeNull(); - }); - - it("folds case only when no exact match exists", () => { - expect( - pickWorkspaceSuffixMatch("Docs/Plan.md", [{ path: "a/docs/plan.md", kind: "file" }]), - ).toBe("a/docs/plan.md"); - expect( - pickWorkspaceSuffixMatch("docs/plan.md", [ - { path: "a/docs/plan.md", kind: "file" }, - { path: "b/Docs/Plan.md", kind: "file" }, - ]), - ).toBe("a/docs/plan.md"); - }); - - it("trusts a truncated page only when a fuzzy file follows the match", () => { - const match: WorkspaceEntryCandidate = { path: "apps/web/src/ChatMarkdown.tsx", kind: "file" }; - const fuzzy: WorkspaceEntryCandidate = { path: "apps/web/src/ChatMarkup.tsx", kind: "file" }; - expect(pickWorkspaceSuffixMatch("src/ChatMarkdown.tsx", [match, fuzzy], true)).toBe(match.path); - // The page ends on the match, so the next page could hold another one. - expect(pickWorkspaceSuffixMatch("src/ChatMarkdown.tsx", [fuzzy, match], true)).toBeNull(); - expect(pickWorkspaceSuffixMatch("src/ChatMarkdown.tsx", [fuzzy, match])).toBe(match.path); - }); - - it("ignores directories", () => { - expect( - pickWorkspaceSuffixMatch("src/components", [ - { path: "apps/src/components", kind: "directory" }, - ]), - ).toBeNull(); - }); -}); - describe("findWorkspaceFileMatch", () => { const nestedPlan: WorkspaceEntryCandidate = { - path: "BudgetLens/docs/GROUPS_PLAN.md", + path: "BudgetLens/docs/plan.md", kind: "file", }; const lookup = ( literalExists: boolean, entries: ReadonlyArray | null, truncated = false, - ) => { - const queries: string[] = []; - return { - queries, - literalExists: async () => literalExists, - searchFiles: async (query: string) => { - queries.push(query); - return entries ? { entries, truncated } : null; - }, - }; - }; + ) => ({ + literalExists: vi.fn(async () => literalExists), + searchFiles: vi.fn(async (_query: string) => (entries ? { entries, truncated } : null)), + }); - it("keeps an existing literal path even when the index holds only a nested twin", async () => { - // The literal root file can be gitignored, so the index cannot vouch for it. + it("keeps the literal path without searching unless it is known to be missing", async () => { + // The literal file may be ignored by the index; metadata uncertainty also keeps it. const deps = lookup(true, [nestedPlan]); - await expect(findWorkspaceFileMatch("docs/GROUPS_PLAN.md", deps)).resolves.toBeNull(); - expect(deps.queries).toEqual([]); + await expect(findWorkspaceFileMatch("docs/plan.md", deps)).resolves.toBeNull(); + expect(deps.searchFiles).not.toHaveBeenCalled(); + }); + + it("finds a unique nested file when the literal path is missing", async () => { + const deps = lookup(false, [nestedPlan]); + await expect(findWorkspaceFileMatch("docs/plan.md", deps)).resolves.toBe(nestedPlan.path); + expect(deps.searchFiles).toHaveBeenCalledWith("docs/plan.md"); }); - it("opens the unique nested match when the literal path is missing", async () => { + it("prefers exact case, then accepts a unique case-folded suffix across separators", async () => { + const caseTwin: WorkspaceEntryCandidate = { path: "Other/Docs/Plan.md", kind: "file" }; + await expect( + findWorkspaceFileMatch("docs/plan.md", lookup(false, [caseTwin, nestedPlan])), + ).resolves.toBe(nestedPlan.path); await expect( - findWorkspaceFileMatch("docs/GROUPS_PLAN.md", lookup(false, [nestedPlan])), - ).resolves.toBe("BudgetLens/docs/GROUPS_PLAN.md"); + findWorkspaceFileMatch("DOCS\\PLAN.MD", lookup(false, [nestedPlan])), + ).resolves.toBe(nestedPlan.path); + await expect( + findWorkspaceFileMatch( + "docs/plan.md", + lookup(false, [{ ...nestedPlan, path: "app\\docs\\plan.md" }]), + ), + ).resolves.toBe("app\\docs\\plan.md"); }); - it("keeps the literal path when the missing file has no unique match", async () => { + it("keeps the literal path for ambiguous suffixes, partial segments, or directories", async () => { + await expect( + findWorkspaceFileMatch( + "docs/plan.md", + lookup(false, [nestedPlan, { path: "Other/docs/plan.md", kind: "file" }]), + ), + ).resolves.toBeNull(); await expect( findWorkspaceFileMatch( - "docs/GROUPS_PLAN.md", - lookup(false, [nestedPlan, { path: "Other/docs/GROUPS_PLAN.md", kind: "file" }]), + "DOCS/PLAN.MD", + lookup(false, [nestedPlan, { path: "Other/Docs/Plan.md", kind: "file" }]), ), ).resolves.toBeNull(); await expect( - findWorkspaceFileMatch("docs/GROUPS_PLAN.md", lookup(false, null)), + findWorkspaceFileMatch( + "docs/plan.md", + lookup(false, [ + { path: "app/mydocs/plan.md", kind: "file" }, + { path: "app/docs/plan.md", kind: "directory" }, + ]), + ), ).resolves.toBeNull(); }); - it("passes a truncated page through to the suffix pick", async () => { - const fuzzy: WorkspaceEntryCandidate = { path: "docs/GROUPS_PLAN.old.md", kind: "file" }; + it("rejects truncated suffix results even when a fuzzy file follows the only match", async () => { + // Truncation also signals an incomplete scan, so page order cannot prove uniqueness. + const fuzzy: WorkspaceEntryCandidate = { path: "docs/plan.old.md", kind: "file" }; await expect( - findWorkspaceFileMatch("docs/GROUPS_PLAN.md", lookup(false, [nestedPlan, fuzzy], true)), - ).resolves.toBe("BudgetLens/docs/GROUPS_PLAN.md"); + findWorkspaceFileMatch("docs/plan.md", lookup(false, [nestedPlan], true)), + ).resolves.toBeNull(); await expect( - findWorkspaceFileMatch("docs/GROUPS_PLAN.md", lookup(false, [fuzzy, nestedPlan], true)), + findWorkspaceFileMatch("docs/plan.md", lookup(false, [nestedPlan, fuzzy], true)), ).resolves.toBeNull(); }); - it("still sends a bare name straight to the index", async () => { - const deps = lookup(true, [{ path: "apps/web/src/ChatView.tsx", kind: "file" }]); - await expect(findWorkspaceFileMatch("ChatView.tsx", deps)).resolves.toBe( - "apps/web/src/ChatView.tsx", - ); - expect(deps.queries).toEqual(["ChatView.tsx"]); + it("keeps the literal path when search fails or returns no files", async () => { + await expect(findWorkspaceFileMatch("docs/plan.md", lookup(false, null))).resolves.toBeNull(); + await expect(findWorkspaceFileMatch("docs/plan.md", lookup(false, []))).resolves.toBeNull(); }); - it("leaves workspace-root links alone", async () => { - const deps = lookup(false, [nestedPlan]); - await expect(findWorkspaceFileMatch(".", deps)).resolves.toBeNull(); - expect(deps.queries).toEqual([]); + it("still resolves bare filenames without checking the literal, even with truncated duplicates", async () => { + const deps = lookup(true, [nestedPlan, { path: "Other/plan.md", kind: "file" }], true); + await expect(findWorkspaceFileMatch("plan.md", deps)).resolves.toBe(nestedPlan.path); + expect(deps.literalExists).not.toHaveBeenCalled(); + expect(deps.searchFiles).toHaveBeenCalledWith("plan.md"); }); }); From 0b07b06d90c7545e891e0675c0174c296f3312da Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 20:38:38 +0000 Subject: [PATCH 5/7] fix(web): resolve missing file chip paths consistently --- apps/web/src/components/ChatMarkdown.tsx | 175 +++++++++++++---------- apps/web/src/workspaceBasenameLookup.ts | 38 ++--- 2 files changed, 114 insertions(+), 99 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 9ec6ec2db051..46e863dac26d 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -172,6 +172,7 @@ import { rewriteMarkdownFileUriHref, shouldOpenMarkdownFileLinkInBrowserByDefault, shouldOpenMarkdownFileLinkInEditor, + withMarkdownFileLinkPath, type MarkdownFileLinkMeta, } from "../markdown-links"; import { isMarkdownFileLinkLabel } from "@t3tools/shared/markdownLinks"; @@ -197,8 +198,7 @@ import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { projectEnvironment } from "../state/projects"; import { claimWorkspaceBasenameLookup, - needsWorkspaceBasenameLookup, - pickWorkspaceBasenameMatch, + findWorkspaceFileMatch, WORKSPACE_BASENAME_LOOKUP_LIMIT, } from "../workspaceBasenameLookup"; import { @@ -1203,15 +1203,14 @@ interface MarkdownFileLinkProps { href: string; targetPath: string; iconPath: string; - displayPath: string; panelPath: string | null; - line?: number | undefined; + resolveLink: () => Promise; label: string; copyMarkdown: string; theme: "light" | "dark"; threadRef?: ScopedThreadRef | undefined; - onOpen?: ((targetPath: string) => Promise>) | undefined; - onOpenInPanel: (panelPath: string, line: number | undefined) => void; + onOpen?: (() => Promise>) | undefined; + onOpenInPanel: () => void; openInEditorMenuLabel: string; onOpenInBrowser?: (() => Promise>) | undefined; onOpenMedia?: (() => void) | undefined; @@ -1940,9 +1939,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ href, targetPath, iconPath, - displayPath, panelPath, - line, + resolveLink, label, copyMarkdown, theme, @@ -1961,7 +1959,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ } void (async () => { try { - const result = await onOpen(targetPath); + const result = await onOpen(); if (result._tag === "Success" || isAtomCommandInterrupted(result)) { return; } @@ -1995,7 +1993,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ const handleOpenInFilePreview = () => { if (threadRef && panelPath) { - onOpenInPanel(panelPath, line); + onOpenInPanel(); return; } if (onOpenMedia) { @@ -2081,7 +2079,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ })(); }; - const handleCopy = (value: string, title: string) => { + const handleCopy = (value: Promise, title: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { toastManager.add( stackedThreadToast({ @@ -2093,12 +2091,21 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ return; } - void navigator.clipboard.writeText(value).then( - () => { + // Keep the user gesture while the path resolves, as required by Safari and Firefox. + const write = + typeof ClipboardItem !== "undefined" && navigator.clipboard.write + ? navigator.clipboard.write([ + new ClipboardItem({ + "text/plain": value.then((text) => new Blob([text], { type: "text/plain" })), + }), + ]) + : value.then((text) => navigator.clipboard.writeText(text)); + void Promise.all([value, write]).then( + ([copied]) => { toastManager.add({ type: "success", title: `${title} copied`, - description: value, + description: copied, }); }, (error) => { @@ -2153,11 +2160,17 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ return; } if (clicked === "copy-relative") { - handleCopy(displayPath, "Relative path"); + handleCopy( + resolveLink().then((link) => link.displayPath), + "Relative path", + ); return; } if (clicked === "copy-full") { - handleCopy(targetPath, "Full path"); + handleCopy( + resolveLink().then((link) => link.targetPath), + "Full path", + ); } } catch (cause) { reportMarkdownActionFailure( @@ -2267,9 +2280,8 @@ function areMarkdownFileLinkPropsEqual( previous.targetPath === next.targetPath && previous.iconPath === next.iconPath && previous.environmentId === next.environmentId && - previous.displayPath === next.displayPath && + previous.resolveLink === next.resolveLink && previous.panelPath === next.panelPath && - previous.line === next.line && previous.label === next.label && previous.copyMarkdown === next.copyMarkdown && previous.theme === next.theme && @@ -2361,7 +2373,10 @@ function useChatMarkdownState({ preview: ExpandedImagePreview; } | null>(null); const localMediaPreview = localMedia?.scope === mediaScopeKey ? localMedia.preview : null; - const closeLocalMediaPreview = () => setLocalMedia(null); + const closeLocalMediaPreview = () => { + mediaRequestId.current += 1; + setLocalMedia(null); + }; const expandMedia = onImageExpand ?? ((preview: ExpandedImagePreview) => setLocalMedia({ scope: mediaScopeKey, preview })); @@ -2376,8 +2391,8 @@ function useChatMarkdownState({ () => () => { mediaRequestId.current += 1; }, - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Leaving the scope invalidates media requests still loading for it. - [mediaScopeKey], + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Changes invalidate media requests from the previous markdown context. + [mediaScopeKey, text, workspaceMutationId], ); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, @@ -2385,6 +2400,11 @@ function useChatMarkdownState({ }); const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, { reportFailure: false, + refresh: true, + }); + const readFileMetadata = useAtomQueryRunner(projectEnvironment.fileMetadata, { + reportFailure: false, + refresh: true, }); const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, @@ -2401,8 +2421,9 @@ function useChatMarkdownState({ source: string, resolvedFilePath?: string, clickedImage?: HTMLImageElement | null, + requestId = ++mediaRequestId.current, ) => { - const requestId = ++mediaRequestId.current; + if (mediaRequestId.current !== requestId) return; void resolveMarkdownMediaPreview({ source, resolvedFilePath, @@ -2609,58 +2630,43 @@ function useChatMarkdownState({ openPreview, }); }; - const findWorkspaceBasenameMatch = async (workspaceRelativePath: string) => { - if ( - !cwd || - environmentId === null || - !readEnvironmentScope(environmentId, AuthFilesystemReadScope) || - !needsWorkspaceBasenameLookup(workspaceRelativePath) - ) { - return null; - } - const result = await searchProjectEntries({ - environmentId, - input: { - cwd, - query: workspaceRelativePath, - limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, - kind: "file", - }, - }); - return result._tag === "Success" - ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries) - : null; - }; - const openFileInPanel = (panelPath: string, line: number | undefined) => { - if (!threadRef) return; - // Claimed on every open so a synchronous one supersedes a lookup already - // in flight. - const isLatestLookup = claimWorkspaceBasenameLookup(); - const openAt = (path: string) => useRightPanelStore.getState().openFile(threadRef, path, line); - if (!cwd || !needsWorkspaceBasenameLookup(panelPath)) { - openAt(panelPath); - return; - } - void (async () => { - const match = await findWorkspaceBasenameMatch(panelPath); - if (!isLatestLookup()) return; - openAt(match ?? panelPath); - })(); - }; - const revealMarkdownFileInFileManager = async (fileLinkMeta: MarkdownFileLinkMeta) => { - const workspaceRelativePath = fileLinkMeta.workspaceRelativePath; - const match = workspaceRelativePath - ? await findWorkspaceBasenameMatch(workspaceRelativePath) - : null; - const filePath = match && cwd ? resolvePathLinkTarget(match, cwd) : fileLinkMeta.filePath; - return revealFileInFileManager(filePath); - }; const fileLinkChip = ( fileLinkMeta: MarkdownFileLinkMeta, copyMarkdown: string, mediaSource?: string, ) => { const mediaPath = mediaSource ?? fileLinkMeta.filePath; + const resolveLink = async () => { + const relativePath = fileLinkMeta.workspaceRelativePath; + if ( + !cwd || + !relativePath || + isAbsolutePath(mediaPath) || + environmentId === null || + !readEnvironmentScope(environmentId, AuthFilesystemReadScope) + ) { + return fileLinkMeta; + } + const match = await findWorkspaceFileMatch(relativePath, { + literalExists: async () => { + const result = await readFileMetadata({ + environmentId, + input: { path: fileLinkMeta.filePath }, + }); + return result._tag !== "Success" || result.value !== null; + }, + searchFiles: async (query) => { + const result = await searchProjectEntries({ + environmentId, + input: { cwd, query, limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, kind: "file" }, + }); + return result._tag === "Success" ? result.value : null; + }, + }); + return match + ? withMarkdownFileLinkPath(fileLinkMeta, resolvePathLinkTarget(match, cwd), cwd) + : fileLinkMeta; + }; const canPreviewMedia = mediaMimeTypeFromExtension( fileLinkMeta.basename.slice(fileLinkMeta.basename.lastIndexOf(".")), @@ -2675,9 +2681,8 @@ function useChatMarkdownState({ href={fileLinkMeta.targetPath} targetPath={fileLinkMeta.targetPath} iconPath={fileLinkMeta.filePath} - displayPath={fileLinkMeta.displayPath} + resolveLink={resolveLink} panelPath={panelPath} - line={fileLinkMeta.line} label={fileLinkLabel( { path: fileLinkMeta.filePath, @@ -2689,17 +2694,37 @@ function useChatMarkdownState({ copyMarkdown={copyMarkdown} theme={resolvedTheme} threadRef={threadRef} - {...(canUseShellActions ? { onOpen: openInPreferredEditor } : {})} - onOpenInPanel={openFileInPanel} + {...(canUseShellActions + ? { onOpen: async () => openInPreferredEditor((await resolveLink()).targetPath) } + : {})} + onOpenInPanel={() => { + if (!threadRef) return; + const isLatestLookup = claimWorkspaceBasenameLookup(); + void resolveLink().then((resolved) => { + if (!isLatestLookup()) return; + useRightPanelStore + .getState() + .openFile( + threadRef, + resolved.workspaceRelativePath ?? resolved.filePath, + resolved.line, + ); + }); + }} onOpenMedia={ threadRef && canPreviewMedia - ? () => openMarkdownMedia(mediaPath, fileLinkMeta.filePath) + ? () => { + const requestId = ++mediaRequestId.current; + void resolveLink().then((resolved) => { + openMarkdownMedia(mediaPath, resolved.filePath, undefined, requestId); + }); + } : undefined } openInEditorMenuLabel={preferredEditorMenuLabel} onReveal={ canUseShellActions && revealInFileManagerLabel !== undefined - ? () => revealMarkdownFileInFileManager(fileLinkMeta) + ? async () => revealFileInFileManager((await resolveLink()).filePath) : undefined } revealLabel={revealInFileManagerLabel} @@ -2708,7 +2733,7 @@ function useChatMarkdownState({ canOperatePreview && isPreviewAvailableFor(threadRef.environmentId) && isBrowserPreviewFile(fileLinkMeta.filePath) - ? () => openMarkdownFileInPreview(fileLinkMeta.filePath) + ? async () => openMarkdownFileInPreview((await resolveLink()).filePath) : undefined } /> diff --git a/apps/web/src/workspaceBasenameLookup.ts b/apps/web/src/workspaceBasenameLookup.ts index 11077044d843..d8264565b384 100644 --- a/apps/web/src/workspaceBasenameLookup.ts +++ b/apps/web/src/workspaceBasenameLookup.ts @@ -51,17 +51,9 @@ function slashPath(path: string): string { return path.trim().replaceAll("\\", "/"); } -/** - * The one indexed file whose path ends with every segment of `relativePath`, - * e.g. `docs/plan.md` in `BudgetLens/docs/plan.md`. Two candidates have no - * right answer, so they resolve to nothing. The index ranks whole-suffix - * matches ahead of fuzzy ones, so a truncated page can hide another match - * only when its last file is a match too. - */ -export function pickWorkspaceSuffixMatch( +function pickWorkspaceSuffixMatch( relativePath: string, entries: ReadonlyArray, - truncated = false, ): string | null { const target = slashPath(relativePath); if (!target) return null; @@ -75,18 +67,9 @@ export function pickWorkspaceSuffixMatch( }; const exact = matching((path) => path); const matches = exact.length > 0 ? exact : matching((path) => path.toLowerCase()); - const match = matches.length === 1 ? matches[0] : undefined; - if (!match || (truncated && files.at(-1) === match)) return null; - return match.path; + return matches.length === 1 ? (matches[0]?.path ?? null) : null; } -/** - * Which workspace file a chip's workspace-relative path opens, or null to keep - * the literal path. Agents name files relative to the folder they work in, so - * a bare name asks the index, and a path with directories asks it only when - * the literal file is missing: the index skips ignored files, so its silence - * proves nothing about the literal path. - */ export async function findWorkspaceFileMatch( relativePath: string, lookup: { @@ -99,11 +82,18 @@ export async function findWorkspaceFileMatch( } | null>; }, ): Promise { - if (needsWorkspaceBasenameLookup(relativePath)) { + try { + if (needsWorkspaceBasenameLookup(relativePath)) { + const result = await lookup.searchFiles(relativePath); + return result ? pickWorkspaceBasenameMatch(relativePath, result.entries) : null; + } + if (!/[\\/]/.test(relativePath) || (await lookup.literalExists()) !== false) return null; const result = await lookup.searchFiles(relativePath); - return result ? pickWorkspaceBasenameMatch(relativePath, result.entries) : null; + // Truncation also covers an incomplete index scan, so uniqueness is unknown. + return result && !result.truncated + ? pickWorkspaceSuffixMatch(relativePath, result.entries) + : null; + } catch { + return null; } - if (!/[\\/]/.test(relativePath) || (await lookup.literalExists())) return null; - const result = await lookup.searchFiles(relativePath); - return result ? pickWorkspaceSuffixMatch(relativePath, result.entries, result.truncated) : null; } From 2504322d182ef7d7c56d05216e3a33eb66cd4fa7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 20:49:43 +0000 Subject: [PATCH 6/7] fix(web): preserve literal file priority and pending media clicks --- apps/web/src/components/ChatMarkdown.tsx | 13 +++++++++++-- apps/web/src/workspaceBasenameLookup.test.ts | 9 ++++++--- apps/web/src/workspaceBasenameLookup.ts | 7 +++---- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 46e863dac26d..3b422b145754 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -2391,8 +2391,8 @@ function useChatMarkdownState({ () => () => { mediaRequestId.current += 1; }, - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Changes invalidate media requests from the previous markdown context. - [mediaScopeKey, text, workspaceMutationId], + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Leaving the scope invalidates pending media requests; streamed text updates keep them alive. + [mediaScopeKey], ); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, @@ -2402,6 +2402,9 @@ function useChatMarkdownState({ reportFailure: false, refresh: true, }); + const invalidateFileMetadata = useAtomCommand(projectEnvironment.invalidateFileMetadata, { + reportFailure: false, + }); const readFileMetadata = useAtomQueryRunner(projectEnvironment.fileMetadata, { reportFailure: false, refresh: true, @@ -2649,6 +2652,12 @@ function useChatMarkdownState({ } const match = await findWorkspaceFileMatch(relativePath, { literalExists: async () => { + // A refreshed query still reuses retained metadata, including cached misses. + const invalidated = await invalidateFileMetadata({ + environmentId, + input: { paths: [fileLinkMeta.filePath] }, + }); + if (invalidated._tag !== "Success") return true; const result = await readFileMetadata({ environmentId, input: { path: fileLinkMeta.filePath }, diff --git a/apps/web/src/workspaceBasenameLookup.test.ts b/apps/web/src/workspaceBasenameLookup.test.ts index d204827c6086..566e8be56831 100644 --- a/apps/web/src/workspaceBasenameLookup.test.ts +++ b/apps/web/src/workspaceBasenameLookup.test.ts @@ -96,13 +96,16 @@ describe("findWorkspaceFileMatch", () => { // The literal file may be ignored by the index; metadata uncertainty also keeps it. const deps = lookup(true, [nestedPlan]); await expect(findWorkspaceFileMatch("docs/plan.md", deps)).resolves.toBeNull(); + await expect(findWorkspaceFileMatch("./docs/plan.md", deps)).resolves.toBeNull(); expect(deps.searchFiles).not.toHaveBeenCalled(); }); it("finds a unique nested file when the literal path is missing", async () => { - const deps = lookup(false, [nestedPlan]); - await expect(findWorkspaceFileMatch("docs/plan.md", deps)).resolves.toBe(nestedPlan.path); - expect(deps.searchFiles).toHaveBeenCalledWith("docs/plan.md"); + for (const path of ["docs/plan.md", "./docs/plan.md"]) { + const deps = lookup(false, [nestedPlan]); + await expect(findWorkspaceFileMatch(path, deps)).resolves.toBe(nestedPlan.path); + expect(deps.searchFiles).toHaveBeenCalledWith("docs/plan.md"); + } }); it("prefers exact case, then accepts a unique case-folded suffix across separators", async () => { diff --git a/apps/web/src/workspaceBasenameLookup.ts b/apps/web/src/workspaceBasenameLookup.ts index d8264565b384..7f7ad9dee687 100644 --- a/apps/web/src/workspaceBasenameLookup.ts +++ b/apps/web/src/workspaceBasenameLookup.ts @@ -88,11 +88,10 @@ export async function findWorkspaceFileMatch( return result ? pickWorkspaceBasenameMatch(relativePath, result.entries) : null; } if (!/[\\/]/.test(relativePath) || (await lookup.literalExists()) !== false) return null; - const result = await lookup.searchFiles(relativePath); + const query = slashPath(relativePath).replace(/^(?:\.\/)+/, ""); + const result = await lookup.searchFiles(query); // Truncation also covers an incomplete index scan, so uniqueness is unknown. - return result && !result.truncated - ? pickWorkspaceSuffixMatch(relativePath, result.entries) - : null; + return result && !result.truncated ? pickWorkspaceSuffixMatch(query, result.entries) : null; } catch { return null; } From 1a743ca89856b31a30806f1ce3a36f69eb25a72f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 21:09:58 +0000 Subject: [PATCH 7/7] fix(web): avoid markdown media React Compiler bailout --- apps/web/src/components/ChatMarkdown.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 3b422b145754..06347b938fda 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -2424,8 +2424,9 @@ function useChatMarkdownState({ source: string, resolvedFilePath?: string, clickedImage?: HTMLImageElement | null, - requestId = ++mediaRequestId.current, + claimedRequestId?: number, ) => { + const requestId = claimedRequestId ?? ++mediaRequestId.current; if (mediaRequestId.current !== requestId) return; void resolveMarkdownMediaPreview({ source,