From d477eceaa28a946bf8e262617346ec9708eda1bc Mon Sep 17 00:00:00 2001 From: Chirag Bangera Date: Wed, 26 Aug 2026 01:08:58 -0400 Subject: [PATCH 01/13] fix(web): resolve slashed file-chip paths through the workspace index --- apps/web/src/components/ChatMarkdown.tsx | 12 ++--- apps/web/src/workspaceBasenameLookup.test.ts | 47 ++++++++++++++------ apps/web/src/workspaceBasenameLookup.ts | 15 +++++-- 3 files changed, 50 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index dd21a4e1bf40..b8c05d833e16 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -115,7 +115,6 @@ import { projectEnvironment } from "../state/projects"; import { threadEnvironment } from "../state/threads"; import { claimWorkspaceBasenameLookup, - needsWorkspaceBasenameLookup, pickWorkspaceBasenameMatch, WORKSPACE_BASENAME_LOOKUP_LIMIT, } from "../workspaceBasenameLookup"; @@ -1836,7 +1835,7 @@ function ChatMarkdown({ ); const findWorkspaceBasenameMatch = useCallback( async (workspaceRelativePath: string) => { - if (!cwd || environmentId === null || !needsWorkspaceBasenameLookup(workspaceRelativePath)) { + if (!cwd || environmentId === null) { return null; } const result = await searchProjectEntries({ @@ -1854,8 +1853,9 @@ function ChatMarkdown({ }, [cwd, environmentId, searchProjectEntries], ); - // A bare filename resolves to the workspace root, which is rarely where the - // file is, so ask the index before opening. + // The message's path is relative to wherever the agent was working, which is + // not always the workspace root the panel reads against, so every open goes + // through the index first; a miss falls back to the literal path. const openFileInPanel = useCallback( (workspaceRelativePath: string, line: number | undefined) => { if (!threadRef) return; @@ -1864,7 +1864,7 @@ function ChatMarkdown({ const isLatestLookup = claimWorkspaceBasenameLookup(); const openAt = (path: string) => useRightPanelStore.getState().openFile(threadRef, path, line); - if (!cwd || !needsWorkspaceBasenameLookup(workspaceRelativePath)) { + if (!cwd || environmentId === null) { openAt(workspaceRelativePath); return; } @@ -1874,7 +1874,7 @@ function ChatMarkdown({ openAt(match ?? workspaceRelativePath); })(); }, - [cwd, findWorkspaceBasenameMatch, threadRef], + [cwd, environmentId, findWorkspaceBasenameMatch, threadRef], ); const revealMarkdownFileInFileManager = useCallback( async (fileLinkMeta: MarkdownFileLinkMeta) => { diff --git a/apps/web/src/workspaceBasenameLookup.test.ts b/apps/web/src/workspaceBasenameLookup.test.ts index b4a1376e79c3..260622f29b71 100644 --- a/apps/web/src/workspaceBasenameLookup.test.ts +++ b/apps/web/src/workspaceBasenameLookup.test.ts @@ -2,23 +2,9 @@ import { describe, expect, it } from "vite-plus/test"; import { claimWorkspaceBasenameLookup, - needsWorkspaceBasenameLookup, pickWorkspaceBasenameMatch, } from "./workspaceBasenameLookup"; -describe("needsWorkspaceBasenameLookup", () => { - it("flags bare filenames", () => { - expect(needsWorkspaceBasenameLookup("ChatView.tsx")).toBe(true); - expect(needsWorkspaceBasenameLookup("Makefile")).toBe(true); - }); - - it("leaves anything with a directory alone", () => { - expect(needsWorkspaceBasenameLookup("apps/web/src/components/ChatView.tsx")).toBe(false); - expect(needsWorkspaceBasenameLookup("apps\\web\\ChatView.tsx")).toBe(false); - expect(needsWorkspaceBasenameLookup(" ")).toBe(false); - }); -}); - describe("pickWorkspaceBasenameMatch", () => { const entries = [ { path: "apps/web/src/components/ChatView.test.tsx", kind: "file" as const }, @@ -31,6 +17,39 @@ describe("pickWorkspaceBasenameMatch", () => { ); }); + it("takes an exact workspace-relative path", () => { + expect(pickWorkspaceBasenameMatch("apps/web/src/components/ChatView.tsx", entries)).toBe( + "apps/web/src/components/ChatView.tsx", + ); + }); + + it("matches a slashed path as a suffix wherever it actually lives", () => { + expect( + pickWorkspaceBasenameMatch("docs/GROUPS_PLAN.md", [ + { path: "BudgetLens/docs/GROUPS_PLAN.md", kind: "file" }, + { path: "docs/other.md", kind: "file" }, + ]), + ).toBe("BudgetLens/docs/GROUPS_PLAN.md"); + }); + + it("does not let a suffix match straddle a segment boundary", () => { + expect( + pickWorkspaceBasenameMatch("docs/plan.md", [{ path: "src/mydocs/plan.md", kind: "file" }]), + ).toBeNull(); + expect( + pickWorkspaceBasenameMatch("plan.md", [{ path: "src/plan.md.bak", kind: "file" }]), + ).toBeNull(); + }); + + it("prefers the exact suffix over a basename-only twin", () => { + expect( + pickWorkspaceBasenameMatch("docs/plan.md", [ + { path: "other/plan.md", kind: "file" }, + { path: "BudgetLens/docs/plan.md", kind: "file" }, + ]), + ).toBe("BudgetLens/docs/plan.md"); + }); + it("ignores directories", () => { expect( pickWorkspaceBasenameMatch("components", [ diff --git a/apps/web/src/workspaceBasenameLookup.ts b/apps/web/src/workspaceBasenameLookup.ts index b99d3ba4ded9..ef9cb641882f 100644 --- a/apps/web/src/workspaceBasenameLookup.ts +++ b/apps/web/src/workspaceBasenameLookup.ts @@ -23,18 +23,25 @@ function basenameOfPath(path: string): string { return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; } -export function needsWorkspaceBasenameLookup(relativePath: string): boolean { - const trimmed = relativePath.trim(); - return trimmed.length > 0 && !trimmed.includes("/") && !trimmed.includes("\\"); +/** `a/b/c` matches `c` and `b/c` at a segment boundary, but not `abc`. */ +function hasSegmentSuffix(path: string, suffix: string): boolean { + return path === suffix || path.endsWith(`/${suffix}`); } export function pickWorkspaceBasenameMatch( basename: string, entries: ReadonlyArray, ): string | null { - const target = basename.trim(); + const target = basename.trim().replaceAll("\\", "/"); if (!target) return null; const files = entries.filter((entry) => entry.kind === "file"); + const exactPath = files.find((entry) => entry.path === target); + if (exactPath) return exactPath.path; + // Agents reference files relative to their own cwd, which is not always the + // workspace root, so a `docs/plan.md` chip has to match wherever that suffix + // actually lives. Index order (frecency) breaks ties. + const suffixMatches = files.filter((entry) => hasSegmentSuffix(entry.path, target)); + if (suffixMatches.length > 0) return suffixMatches[0]?.path ?? null; const exact = files.find((entry) => basenameOfPath(entry.path) === target); if (exact) return exact.path; // Folded matching covers casing that drifted from disk, but `FOO.ts` against From e6b8575510f22d29181681bb8a44d78b7d102d74 Mon Sep 17 00:00:00 2001 From: Chirag Bangera Date: Wed, 26 Aug 2026 01:28:47 -0400 Subject: [PATCH 02/13] fix(web): file-chip lookup strips ./ and matches slashed paths case-insensitively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chip paths like ./docs/plan.md kept the leading ./, so suffix matching missed the real file. Slashed lookups were also case-sensitive, and the folded fallback compared only the basename, so Docs/Plan.md never resolved to project/docs/plan.md. Normalize the lookup path (and the index query) before matching, then try an exact suffix and a unique case-folded suffix before falling back to basename. Model: grok-4.6 · Harness: grok --- apps/web/src/components/ChatMarkdown.tsx | 8 +++-- apps/web/src/workspaceBasenameLookup.test.ts | 37 ++++++++++++++++++++ apps/web/src/workspaceBasenameLookup.ts | 33 +++++++++++++---- 3 files changed, 68 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b8c05d833e16..07974a147c9f 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -115,6 +115,7 @@ import { projectEnvironment } from "../state/projects"; import { threadEnvironment } from "../state/threads"; import { claimWorkspaceBasenameLookup, + normalizeWorkspaceLookupPath, pickWorkspaceBasenameMatch, WORKSPACE_BASENAME_LOOKUP_LIMIT, } from "../workspaceBasenameLookup"; @@ -1835,20 +1836,21 @@ function ChatMarkdown({ ); const findWorkspaceBasenameMatch = useCallback( async (workspaceRelativePath: string) => { - if (!cwd || environmentId === null) { + const lookupPath = normalizeWorkspaceLookupPath(workspaceRelativePath); + if (!cwd || environmentId === null || !lookupPath) { return null; } const result = await searchProjectEntries({ environmentId, input: { cwd, - query: workspaceRelativePath, + query: lookupPath, limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, kind: "file", }, }); return result._tag === "Success" - ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries) + ? pickWorkspaceBasenameMatch(lookupPath, result.value.entries) : null; }, [cwd, environmentId, searchProjectEntries], diff --git a/apps/web/src/workspaceBasenameLookup.test.ts b/apps/web/src/workspaceBasenameLookup.test.ts index 260622f29b71..664c3fa7c780 100644 --- a/apps/web/src/workspaceBasenameLookup.test.ts +++ b/apps/web/src/workspaceBasenameLookup.test.ts @@ -50,6 +50,43 @@ describe("pickWorkspaceBasenameMatch", () => { ).toBe("BudgetLens/docs/plan.md"); }); + it("strips leading ./ so cwd-relative chips still suffix-match", () => { + expect( + pickWorkspaceBasenameMatch("./docs/plan.md", [ + { path: "BudgetLens/docs/plan.md", kind: "file" }, + ]), + ).toBe("BudgetLens/docs/plan.md"); + expect( + pickWorkspaceBasenameMatch(".\\docs\\plan.md", [ + { path: "BudgetLens/docs/plan.md", kind: "file" }, + ]), + ).toBe("BudgetLens/docs/plan.md"); + }); + + it("matches a slashed path ignoring case when the suffix is unique", () => { + expect( + pickWorkspaceBasenameMatch("Docs/Plan.md", [{ path: "project/docs/plan.md", kind: "file" }]), + ).toBe("project/docs/plan.md"); + }); + + it("prefers the exactly-cased suffix over a case-only twin", () => { + expect( + pickWorkspaceBasenameMatch("docs/plan.md", [ + { path: "a/Docs/Plan.md", kind: "file" }, + { path: "b/docs/plan.md", kind: "file" }, + ]), + ).toBe("b/docs/plan.md"); + }); + + it("returns null when a case-folded suffix is ambiguous", () => { + expect( + pickWorkspaceBasenameMatch("Docs/Plan.md", [ + { path: "a/docs/plan.md", kind: "file" }, + { path: "c/DOCS/PLAN.md", kind: "file" }, + ]), + ).toBeNull(); + }); + it("ignores directories", () => { expect( pickWorkspaceBasenameMatch("components", [ diff --git a/apps/web/src/workspaceBasenameLookup.ts b/apps/web/src/workspaceBasenameLookup.ts index ef9cb641882f..d5ee07c868e2 100644 --- a/apps/web/src/workspaceBasenameLookup.ts +++ b/apps/web/src/workspaceBasenameLookup.ts @@ -18,38 +18,57 @@ export interface WorkspaceEntryCandidate { readonly kind: "file" | "directory"; } +function posixPath(path: string): string { + return path.replaceAll("\\", "/"); +} + function basenameOfPath(path: string): string { - const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); - return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; + const posix = posixPath(path); + const separatorIndex = posix.lastIndexOf("/"); + return separatorIndex >= 0 ? posix.slice(separatorIndex + 1) : posix; +} + +/** Chip paths are cwd-relative; `./docs/plan.md` is the same lookup as `docs/plan.md`. */ +export function normalizeWorkspaceLookupPath(relativePath: string): string { + return relativePath + .trim() + .replaceAll("\\", "/") + .replace(/^(?:\.\/)+/, ""); } /** `a/b/c` matches `c` and `b/c` at a segment boundary, but not `abc`. */ function hasSegmentSuffix(path: string, suffix: string): boolean { - return path === suffix || path.endsWith(`/${suffix}`); + const posix = posixPath(path); + return posix === suffix || posix.endsWith(`/${suffix}`); } export function pickWorkspaceBasenameMatch( basename: string, entries: ReadonlyArray, ): string | null { - const target = basename.trim().replaceAll("\\", "/"); + const target = normalizeWorkspaceLookupPath(basename); if (!target) return null; const files = entries.filter((entry) => entry.kind === "file"); - const exactPath = files.find((entry) => entry.path === target); + const exactPath = files.find((entry) => posixPath(entry.path) === target); if (exactPath) return exactPath.path; // Agents reference files relative to their own cwd, which is not always the // workspace root, so a `docs/plan.md` chip has to match wherever that suffix // actually lives. Index order (frecency) breaks ties. const suffixMatches = files.filter((entry) => hasSegmentSuffix(entry.path, target)); if (suffixMatches.length > 0) return suffixMatches[0]?.path ?? null; + const foldedTarget = target.toLowerCase(); + const foldedSuffixMatches = files.filter((entry) => + hasSegmentSuffix(posixPath(entry.path).toLowerCase(), foldedTarget), + ); + if (foldedSuffixMatches.length === 1) return foldedSuffixMatches[0]?.path ?? null; + if (foldedSuffixMatches.length > 1) return null; const exact = files.find((entry) => basenameOfPath(entry.path) === target); if (exact) return exact.path; // Folded matching covers casing that drifted from disk, but `FOO.ts` against // both `Foo.ts` and `foo.ts` has no right answer, so it resolves to nothing // rather than opening whichever the index ranked first. - const folded = target.toLowerCase(); const foldedMatches = files.filter( - (entry) => basenameOfPath(entry.path).toLowerCase() === folded, + (entry) => basenameOfPath(entry.path).toLowerCase() === foldedTarget, ); return foldedMatches.length === 1 ? (foldedMatches[0]?.path ?? null) : null; } From 3851418d7b2eb57f6f917db1f599b7a6d40909c5 Mon Sep 17 00:00:00 2001 From: Chirag Bangera Date: Wed, 26 Aug 2026 01:33:41 -0400 Subject: [PATCH 03/13] fix(web): scope file-chip lookup claims to the thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The newest-click-wins counter was process-global, so a chip click in thread B could drop an in-flight lookup for thread A even though openFile is per-thread. Claim lookups by thread key so only same-thread clicks supersede each other. Model: grok-4.6 · Harness: grok --- apps/web/src/components/ChatMarkdown.tsx | 3 ++- apps/web/src/workspaceBasenameLookup.test.ts | 14 +++++++++++--- apps/web/src/workspaceBasenameLookup.ts | 14 +++++++------- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 07974a147c9f..4fb0cb5f0da6 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -19,6 +19,7 @@ import type { ServerProviderSkill, ThreadLinkedPullRequest, } from "@t3tools/contracts"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -1863,7 +1864,7 @@ function ChatMarkdown({ if (!threadRef) return; // Claimed on every open so a synchronous one supersedes a lookup already // in flight. - const isLatestLookup = claimWorkspaceBasenameLookup(); + const isLatestLookup = claimWorkspaceBasenameLookup(scopedThreadKey(threadRef)); const openAt = (path: string) => useRightPanelStore.getState().openFile(threadRef, path, line); if (!cwd || environmentId === null) { diff --git a/apps/web/src/workspaceBasenameLookup.test.ts b/apps/web/src/workspaceBasenameLookup.test.ts index 664c3fa7c780..0d7968284e15 100644 --- a/apps/web/src/workspaceBasenameLookup.test.ts +++ b/apps/web/src/workspaceBasenameLookup.test.ts @@ -132,8 +132,8 @@ describe("pickWorkspaceBasenameMatch", () => { describe("claimWorkspaceBasenameLookup", () => { it("keeps only the newest claim, whatever order the lookups settle in", () => { - const first = claimWorkspaceBasenameLookup(); - const second = claimWorkspaceBasenameLookup(); + const first = claimWorkspaceBasenameLookup("thread-a"); + const second = claimWorkspaceBasenameLookup("thread-a"); // The older lookup answering last must not reopen the panel behind the // newer one. @@ -142,7 +142,15 @@ describe("claimWorkspaceBasenameLookup", () => { }); it("stays valid while it is the only claim", () => { - const only = claimWorkspaceBasenameLookup(); + const only = claimWorkspaceBasenameLookup("thread-a"); expect(only()).toBe(true); }); + + it("does not let one thread's claim cancel another thread's lookup", () => { + const threadA = claimWorkspaceBasenameLookup("thread-a"); + const threadB = claimWorkspaceBasenameLookup("thread-b"); + + expect(threadB()).toBe(true); + expect(threadA()).toBe(true); + }); }); diff --git a/apps/web/src/workspaceBasenameLookup.ts b/apps/web/src/workspaceBasenameLookup.ts index d5ee07c868e2..3cb6a4425427 100644 --- a/apps/web/src/workspaceBasenameLookup.ts +++ b/apps/web/src/workspaceBasenameLookup.ts @@ -2,15 +2,15 @@ // asking for a full listing on a single click. export const WORKSPACE_BASENAME_LOOKUP_LIMIT = 25; -// One counter for every caller: they all open the same panel, so the newest -// click wins regardless of which one started the lookup. -let latestLookupSequence = 0; +// One counter per thread: that thread's file panel is shared, so the newest +// click wins. Other threads keep their own in-flight lookups. +const latestLookupSequenceByScope = new Map(); /** Call the returned predicate when the search settles; false means a later click superseded it. */ -export function claimWorkspaceBasenameLookup(): () => boolean { - latestLookupSequence += 1; - const claimed = latestLookupSequence; - return () => claimed === latestLookupSequence; +export function claimWorkspaceBasenameLookup(scope: string): () => boolean { + const next = (latestLookupSequenceByScope.get(scope) ?? 0) + 1; + latestLookupSequenceByScope.set(scope, next); + return () => latestLookupSequenceByScope.get(scope) === next; } export interface WorkspaceEntryCandidate { From 6d93059b590cc710f902411631118d084e2a3e12 Mon Sep 17 00:00:00 2001 From: Chirag Bangera Date: Wed, 26 Aug 2026 01:39:02 -0400 Subject: [PATCH 04/13] test(web): drop redundant file-chip lookup cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the slashed case-fold match and per-thread claim isolation. The ./ strip, Windows slash, exact-case preference, and ambiguous-suffix cases only restated the same branches. Model: grok-4.6 · Harness: grok --- apps/web/src/workspaceBasenameLookup.test.ts | 31 -------------------- 1 file changed, 31 deletions(-) diff --git a/apps/web/src/workspaceBasenameLookup.test.ts b/apps/web/src/workspaceBasenameLookup.test.ts index 0d7968284e15..cce03bfa6bdf 100644 --- a/apps/web/src/workspaceBasenameLookup.test.ts +++ b/apps/web/src/workspaceBasenameLookup.test.ts @@ -50,43 +50,12 @@ describe("pickWorkspaceBasenameMatch", () => { ).toBe("BudgetLens/docs/plan.md"); }); - it("strips leading ./ so cwd-relative chips still suffix-match", () => { - expect( - pickWorkspaceBasenameMatch("./docs/plan.md", [ - { path: "BudgetLens/docs/plan.md", kind: "file" }, - ]), - ).toBe("BudgetLens/docs/plan.md"); - expect( - pickWorkspaceBasenameMatch(".\\docs\\plan.md", [ - { path: "BudgetLens/docs/plan.md", kind: "file" }, - ]), - ).toBe("BudgetLens/docs/plan.md"); - }); - it("matches a slashed path ignoring case when the suffix is unique", () => { expect( pickWorkspaceBasenameMatch("Docs/Plan.md", [{ path: "project/docs/plan.md", kind: "file" }]), ).toBe("project/docs/plan.md"); }); - it("prefers the exactly-cased suffix over a case-only twin", () => { - expect( - pickWorkspaceBasenameMatch("docs/plan.md", [ - { path: "a/Docs/Plan.md", kind: "file" }, - { path: "b/docs/plan.md", kind: "file" }, - ]), - ).toBe("b/docs/plan.md"); - }); - - it("returns null when a case-folded suffix is ambiguous", () => { - expect( - pickWorkspaceBasenameMatch("Docs/Plan.md", [ - { path: "a/docs/plan.md", kind: "file" }, - { path: "c/DOCS/PLAN.md", kind: "file" }, - ]), - ).toBeNull(); - }); - it("ignores directories", () => { expect( pickWorkspaceBasenameMatch("components", [ From 49603c1677b53c2f9379fb6740356bc948e05974 Mon Sep 17 00:00:00 2001 From: Chirag Bangera Date: Wed, 26 Aug 2026 01:40:18 -0400 Subject: [PATCH 05/13] test(web): keep only the original file-chip lookup cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extra ./ , case-fold, and per-thread cases were used to verify locally and do not belong in the suite. Model: grok-4.6 · Harness: grok --- apps/web/src/workspaceBasenameLookup.test.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/apps/web/src/workspaceBasenameLookup.test.ts b/apps/web/src/workspaceBasenameLookup.test.ts index cce03bfa6bdf..b1f5bacba263 100644 --- a/apps/web/src/workspaceBasenameLookup.test.ts +++ b/apps/web/src/workspaceBasenameLookup.test.ts @@ -50,12 +50,6 @@ describe("pickWorkspaceBasenameMatch", () => { ).toBe("BudgetLens/docs/plan.md"); }); - it("matches a slashed path ignoring case when the suffix is unique", () => { - expect( - pickWorkspaceBasenameMatch("Docs/Plan.md", [{ path: "project/docs/plan.md", kind: "file" }]), - ).toBe("project/docs/plan.md"); - }); - it("ignores directories", () => { expect( pickWorkspaceBasenameMatch("components", [ @@ -114,12 +108,4 @@ describe("claimWorkspaceBasenameLookup", () => { const only = claimWorkspaceBasenameLookup("thread-a"); expect(only()).toBe(true); }); - - it("does not let one thread's claim cancel another thread's lookup", () => { - const threadA = claimWorkspaceBasenameLookup("thread-a"); - const threadB = claimWorkspaceBasenameLookup("thread-b"); - - expect(threadB()).toBe(true); - expect(threadA()).toBe(true); - }); }); From be3b44514fa745e7ad994a7b00a445548e505807 Mon Sep 17 00:00:00 2001 From: Chirag Bangera Date: Wed, 26 Aug 2026 01:41:39 -0400 Subject: [PATCH 06/13] fix(web): do not guess when a file-chip suffix matches more than one path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ambiguous suffix hits used to open whichever search result ranked first. Return null instead so the panel falls back to the literal chip path. Model: grok-4.6 · Harness: grok --- apps/web/src/workspaceBasenameLookup.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/workspaceBasenameLookup.ts b/apps/web/src/workspaceBasenameLookup.ts index 3cb6a4425427..3f339f5f528a 100644 --- a/apps/web/src/workspaceBasenameLookup.ts +++ b/apps/web/src/workspaceBasenameLookup.ts @@ -53,9 +53,11 @@ export function pickWorkspaceBasenameMatch( if (exactPath) return exactPath.path; // Agents reference files relative to their own cwd, which is not always the // workspace root, so a `docs/plan.md` chip has to match wherever that suffix - // actually lives. Index order (frecency) breaks ties. + // actually lives. An ambiguous suffix resolves to nothing so the caller opens + // the literal path instead of an arbitrarily ranked twin. const suffixMatches = files.filter((entry) => hasSegmentSuffix(entry.path, target)); - if (suffixMatches.length > 0) return suffixMatches[0]?.path ?? null; + if (suffixMatches.length === 1) return suffixMatches[0]?.path ?? null; + if (suffixMatches.length > 1) return null; const foldedTarget = target.toLowerCase(); const foldedSuffixMatches = files.filter((entry) => hasSegmentSuffix(posixPath(entry.path).toLowerCase(), foldedTarget), From 123845d250bb9df2886529472fefd610b060a7ef Mon Sep 17 00:00:00 2001 From: Chirag Bangera Date: Wed, 26 Aug 2026 01:43:24 -0400 Subject: [PATCH 07/13] chore(web): drop leftover comments and a redundant lookup test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model: grok-4.6 · Harness: grok --- apps/web/src/components/ChatMarkdown.tsx | 4 +--- apps/web/src/workspaceBasenameLookup.test.ts | 6 ------ apps/web/src/workspaceBasenameLookup.ts | 9 ++------- 3 files changed, 3 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 4fb0cb5f0da6..29f3daeead6a 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1856,9 +1856,7 @@ function ChatMarkdown({ }, [cwd, environmentId, searchProjectEntries], ); - // The message's path is relative to wherever the agent was working, which is - // not always the workspace root the panel reads against, so every open goes - // through the index first; a miss falls back to the literal path. + // Chip paths are relative to the agent's cwd, so every open goes through the index first. const openFileInPanel = useCallback( (workspaceRelativePath: string, line: number | undefined) => { if (!threadRef) return; diff --git a/apps/web/src/workspaceBasenameLookup.test.ts b/apps/web/src/workspaceBasenameLookup.test.ts index b1f5bacba263..64aec5a36d81 100644 --- a/apps/web/src/workspaceBasenameLookup.test.ts +++ b/apps/web/src/workspaceBasenameLookup.test.ts @@ -17,12 +17,6 @@ describe("pickWorkspaceBasenameMatch", () => { ); }); - it("takes an exact workspace-relative path", () => { - expect(pickWorkspaceBasenameMatch("apps/web/src/components/ChatView.tsx", entries)).toBe( - "apps/web/src/components/ChatView.tsx", - ); - }); - it("matches a slashed path as a suffix wherever it actually lives", () => { expect( pickWorkspaceBasenameMatch("docs/GROUPS_PLAN.md", [ diff --git a/apps/web/src/workspaceBasenameLookup.ts b/apps/web/src/workspaceBasenameLookup.ts index 3f339f5f528a..a488103407cb 100644 --- a/apps/web/src/workspaceBasenameLookup.ts +++ b/apps/web/src/workspaceBasenameLookup.ts @@ -2,8 +2,7 @@ // asking for a full listing on a single click. export const WORKSPACE_BASENAME_LOOKUP_LIMIT = 25; -// One counter per thread: that thread's file panel is shared, so the newest -// click wins. Other threads keep their own in-flight lookups. +// Newest click wins within a thread. Other threads keep their own lookups. const latestLookupSequenceByScope = new Map(); /** Call the returned predicate when the search settles; false means a later click superseded it. */ @@ -28,7 +27,6 @@ function basenameOfPath(path: string): string { return separatorIndex >= 0 ? posix.slice(separatorIndex + 1) : posix; } -/** Chip paths are cwd-relative; `./docs/plan.md` is the same lookup as `docs/plan.md`. */ export function normalizeWorkspaceLookupPath(relativePath: string): string { return relativePath .trim() @@ -51,10 +49,7 @@ export function pickWorkspaceBasenameMatch( const files = entries.filter((entry) => entry.kind === "file"); const exactPath = files.find((entry) => posixPath(entry.path) === target); if (exactPath) return exactPath.path; - // Agents reference files relative to their own cwd, which is not always the - // workspace root, so a `docs/plan.md` chip has to match wherever that suffix - // actually lives. An ambiguous suffix resolves to nothing so the caller opens - // the literal path instead of an arbitrarily ranked twin. + // Chip paths are relative to the agent's cwd, so match the suffix wherever it lives. const suffixMatches = files.filter((entry) => hasSegmentSuffix(entry.path, target)); if (suffixMatches.length === 1) return suffixMatches[0]?.path ?? null; if (suffixMatches.length > 1) return null; From e89e48ad07f6901c9fe33914cfcebba590b8bc3c Mon Sep 17 00:00:00 2001 From: Chirag Bangera Date: Wed, 26 Aug 2026 01:50:52 -0400 Subject: [PATCH 08/13] fix(web): keep ranked-first matching for ambiguous bare file-chip names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slashed suffix twins still resolve to nothing so the literal path opens. Bare names like foo.ts keep the first index hit, which is the old lookup contract. Model: grok-4.6 · Harness: grok --- apps/web/src/workspaceBasenameLookup.test.ts | 15 ++++++++++++--- apps/web/src/workspaceBasenameLookup.ts | 4 +++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/web/src/workspaceBasenameLookup.test.ts b/apps/web/src/workspaceBasenameLookup.test.ts index 64aec5a36d81..f99e78f2165c 100644 --- a/apps/web/src/workspaceBasenameLookup.test.ts +++ b/apps/web/src/workspaceBasenameLookup.test.ts @@ -12,9 +12,12 @@ describe("pickWorkspaceBasenameMatch", () => { ]; it("takes the first exact filename match, not the closest fuzzy one", () => { - expect(pickWorkspaceBasenameMatch("ChatView.tsx", entries)).toBe( - "apps/web/src/components/ChatView.tsx", - ); + expect( + pickWorkspaceBasenameMatch("ChatView.tsx", [ + ...entries, + { path: "apps/desktop/src/ChatView.tsx", kind: "file" }, + ]), + ).toBe("apps/web/src/components/ChatView.tsx"); }); it("matches a slashed path as a suffix wherever it actually lives", () => { @@ -102,4 +105,10 @@ describe("claimWorkspaceBasenameLookup", () => { const only = claimWorkspaceBasenameLookup("thread-a"); expect(only()).toBe(true); }); + + it("keeps another thread's in-flight claim valid", () => { + const threadA = claimWorkspaceBasenameLookup("thread-a"); + claimWorkspaceBasenameLookup("thread-b"); + expect(threadA()).toBe(true); + }); }); diff --git a/apps/web/src/workspaceBasenameLookup.ts b/apps/web/src/workspaceBasenameLookup.ts index a488103407cb..50876bc36e52 100644 --- a/apps/web/src/workspaceBasenameLookup.ts +++ b/apps/web/src/workspaceBasenameLookup.ts @@ -52,7 +52,9 @@ export function pickWorkspaceBasenameMatch( // Chip paths are relative to the agent's cwd, so match the suffix wherever it lives. const suffixMatches = files.filter((entry) => hasSegmentSuffix(entry.path, target)); if (suffixMatches.length === 1) return suffixMatches[0]?.path ?? null; - if (suffixMatches.length > 1) return null; + if (suffixMatches.length > 1) { + return target.includes("/") ? null : (suffixMatches[0]?.path ?? null); + } const foldedTarget = target.toLowerCase(); const foldedSuffixMatches = files.filter((entry) => hasSegmentSuffix(posixPath(entry.path).toLowerCase(), foldedTarget), From ed9cfa5d138733a69592090c705b4352f5a9bbf5 Mon Sep 17 00:00:00 2001 From: Chirag Bangera Date: Wed, 26 Aug 2026 02:06:00 -0400 Subject: [PATCH 09/13] fix(web): resolve file-chip editor opens through the workspace index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open in editor now uses the same index lookup as the panel and reveal, so a slashed chip path opens the real file instead of a cwd-joined miss. Drop the unreachable basename fallbacks after suffix matching. Model: grok-4.6 · Harness: grok --- apps/web/src/components/ChatMarkdown.tsx | 20 ++++++++++++++++++-- apps/web/src/workspaceBasenameLookup.ts | 14 +------------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 29f3daeead6a..a196856ee269 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1888,6 +1888,22 @@ function ChatMarkdown({ }, [cwd, findWorkspaceBasenameMatch, revealFileInFileManager], ); + const openMarkdownFileInEditor = useCallback( + async (fileLinkMeta: MarkdownFileLinkMeta) => { + const workspaceRelativePath = fileLinkMeta.workspaceRelativePath; + const match = workspaceRelativePath + ? await findWorkspaceBasenameMatch(workspaceRelativePath) + : null; + if (!match || !cwd) { + return openInPreferredEditor(fileLinkMeta.targetPath); + } + const withPosition = fileLinkMeta.line + ? `${match}:${fileLinkMeta.line}${fileLinkMeta.column ? `:${fileLinkMeta.column}` : ""}` + : match; + return openInPreferredEditor(resolvePathLinkTarget(withPosition, cwd)); + }, + [cwd, findWorkspaceBasenameMatch, openInPreferredEditor], + ); /* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component * renderers that close over this message's metadata. useMemo keeps them stable until that * metadata changes. */ @@ -1922,7 +1938,7 @@ function ChatMarkdown({ copyMarkdown={copyMarkdown} theme={resolvedTheme} threadRef={threadRef} - {...(canUseShellActions ? { onOpen: openInPreferredEditor } : {})} + {...(canUseShellActions ? { onOpen: () => openMarkdownFileInEditor(fileLinkMeta) } : {})} onOpenInPanel={openFileInPanel} openInEditorMenuLabel={preferredEditorMenuLabel} onReveal={ @@ -2218,7 +2234,7 @@ function ChatMarkdown({ markdownFileLinkMetaByHref, onTaskListChange, openFileInPanel, - openInPreferredEditor, + openMarkdownFileInEditor, openChangeRequestLink, openExternalLinkInPreview, openMarkdownFileInPreview, diff --git a/apps/web/src/workspaceBasenameLookup.ts b/apps/web/src/workspaceBasenameLookup.ts index 50876bc36e52..f1959cdb8cb8 100644 --- a/apps/web/src/workspaceBasenameLookup.ts +++ b/apps/web/src/workspaceBasenameLookup.ts @@ -21,12 +21,6 @@ function posixPath(path: string): string { return path.replaceAll("\\", "/"); } -function basenameOfPath(path: string): string { - const posix = posixPath(path); - const separatorIndex = posix.lastIndexOf("/"); - return separatorIndex >= 0 ? posix.slice(separatorIndex + 1) : posix; -} - export function normalizeWorkspaceLookupPath(relativePath: string): string { return relativePath .trim() @@ -60,14 +54,8 @@ export function pickWorkspaceBasenameMatch( hasSegmentSuffix(posixPath(entry.path).toLowerCase(), foldedTarget), ); if (foldedSuffixMatches.length === 1) return foldedSuffixMatches[0]?.path ?? null; - if (foldedSuffixMatches.length > 1) return null; - const exact = files.find((entry) => basenameOfPath(entry.path) === target); - if (exact) return exact.path; // Folded matching covers casing that drifted from disk, but `FOO.ts` against // both `Foo.ts` and `foo.ts` has no right answer, so it resolves to nothing // rather than opening whichever the index ranked first. - const foldedMatches = files.filter( - (entry) => basenameOfPath(entry.path).toLowerCase() === foldedTarget, - ); - return foldedMatches.length === 1 ? (foldedMatches[0]?.path ?? null) : null; + return null; } From e42c030c7a285c9d91aa67ae61c9fd07174d8fe0 Mon Sep 17 00:00:00 2001 From: Chirag Bangera Date: Wed, 26 Aug 2026 02:13:35 -0400 Subject: [PATCH 10/13] fix(web): resolve file-chip browser opens through the workspace index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PDF and HTML chip clicks now use the same index lookup as panel, reveal, and editor so a slashed path opens the real file. Model: grok-4.6 · Harness: grok --- apps/web/src/components/ChatMarkdown.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index a196856ee269..e5ba440fddbd 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1904,6 +1904,18 @@ function ChatMarkdown({ }, [cwd, findWorkspaceBasenameMatch, openInPreferredEditor], ); + const openMarkdownFileInBrowser = useCallback( + async (fileLinkMeta: MarkdownFileLinkMeta) => { + const workspaceRelativePath = fileLinkMeta.workspaceRelativePath; + const match = workspaceRelativePath + ? await findWorkspaceBasenameMatch(workspaceRelativePath) + : null; + return openMarkdownFileInPreview( + match && cwd ? resolvePathLinkTarget(match, cwd) : fileLinkMeta.filePath, + ); + }, + [cwd, findWorkspaceBasenameMatch, openMarkdownFileInPreview], + ); /* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component * renderers that close over this message's metadata. useMemo keeps them stable until that * metadata changes. */ @@ -1951,7 +1963,7 @@ function ChatMarkdown({ threadRef && isPreviewSupportedInRuntime() && isBrowserPreviewFile(fileLinkMeta.filePath) - ? () => openMarkdownFileInPreview(fileLinkMeta.filePath) + ? () => openMarkdownFileInBrowser(fileLinkMeta) : undefined } className={className} @@ -2237,7 +2249,7 @@ function ChatMarkdown({ openMarkdownFileInEditor, openChangeRequestLink, openExternalLinkInPreview, - openMarkdownFileInPreview, + openMarkdownFileInBrowser, preferredEditorMenuLabel, resolveThreadPullRequest, resolvedTheme, From 2ab5a2a89d272b944a3bcda9834f1eded5b4f548 Mon Sep 17 00:00:00 2001 From: Chirag Bangera Date: Fri, 11 Sep 2026 23:55:18 -0400 Subject: [PATCH 11/13] fix(web): keep existing literal chip paths ahead of divergent index hits --- apps/web/src/components/ChatMarkdown.tsx | 34 +++++++++++++++++--- apps/web/src/workspaceBasenameLookup.test.ts | 28 ++++++++++++++++ apps/web/src/workspaceBasenameLookup.ts | 14 ++++++++ 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 6171f8a94f5e..a7e284f54313 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -163,6 +163,7 @@ import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { projectEnvironment } from "../state/projects"; import { claimWorkspaceBasenameLookup, + needsLiteralWorkspaceFileCheck, normalizeWorkspaceLookupPath, pickWorkspaceBasenameMatch, WORKSPACE_BASENAME_LOOKUP_LIMIT, @@ -2209,6 +2210,9 @@ function useChatMarkdownState({ const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, { reportFailure: false, }); + const readWorkspaceFile = useAtomQueryRunner(projectEnvironment.readFile, { + reportFailure: false, + }); const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); @@ -2437,6 +2441,20 @@ function useChatMarkdownState({ }, [createAssetUrl, cwd, openPreview, preparedConnection, threadRef], ); + const literalWorkspaceFileExists = useCallback( + async (workspaceRelativePath: string) => { + if (!cwd || environmentId === null) return false; + const result = await readWorkspaceFile({ + environmentId, + input: { cwd, relativePath: workspaceRelativePath }, + }); + // readFile reads from disk rather than the search index, so gitignored + // files still report present. Binary files fail the read and count as + // missing here; a stat RPC would close that gap. + return result._tag === "Success"; + }, + [cwd, environmentId, readWorkspaceFile], + ); const findWorkspaceBasenameMatch = useCallback( async (workspaceRelativePath: string) => { const lookupPath = normalizeWorkspaceLookupPath(workspaceRelativePath); @@ -2452,11 +2470,19 @@ function useChatMarkdownState({ kind: "file", }, }); - return result._tag === "Success" - ? pickWorkspaceBasenameMatch(lookupPath, result.value.entries) - : null; + if (result._tag !== "Success") return null; + const match = pickWorkspaceBasenameMatch(lookupPath, result.value.entries); + if ( + needsLiteralWorkspaceFileCheck(lookupPath, match) && + (await literalWorkspaceFileExists(lookupPath)) + ) { + // The literal chip path names a real file, so keep it instead of the + // indexed twin. Callers fall back to the literal path on null. + return null; + } + return match; }, - [cwd, environmentId, searchProjectEntries], + [cwd, environmentId, literalWorkspaceFileExists, searchProjectEntries], ); // Chip paths are relative to the agent's cwd, so every workspace open goes // through the index first. Absolute host paths open as-is. diff --git a/apps/web/src/workspaceBasenameLookup.test.ts b/apps/web/src/workspaceBasenameLookup.test.ts index f99e78f2165c..7e6b0e3cfe1b 100644 --- a/apps/web/src/workspaceBasenameLookup.test.ts +++ b/apps/web/src/workspaceBasenameLookup.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { claimWorkspaceBasenameLookup, + needsLiteralWorkspaceFileCheck, pickWorkspaceBasenameMatch, } from "./workspaceBasenameLookup"; @@ -90,6 +91,33 @@ describe("pickWorkspaceBasenameMatch", () => { }); }); +describe("needsLiteralWorkspaceFileCheck", () => { + it("checks the disk when a slashed hit names a different file than the chip", () => { + expect( + needsLiteralWorkspaceFileCheck("docs/GROUPS_PLAN.md", "BudgetLens/docs/GROUPS_PLAN.md"), + ).toBe(true); + }); + + it("skips the disk when the hit is the literal path", () => { + expect( + needsLiteralWorkspaceFileCheck( + "apps/web/src/components/ChatView.tsx", + "apps/web/src/components/ChatView.tsx", + ), + ).toBe(false); + }); + + it("skips the disk when there is no hit", () => { + expect(needsLiteralWorkspaceFileCheck("docs/plan.md", null)).toBe(false); + }); + + it("skips the disk for bare names, which keep the ranked-first hit", () => { + expect(needsLiteralWorkspaceFileCheck("ChatView.tsx", "apps/web/src/ChatView.tsx")).toBe( + false, + ); + }); +}); + describe("claimWorkspaceBasenameLookup", () => { it("keeps only the newest claim, whatever order the lookups settle in", () => { const first = claimWorkspaceBasenameLookup("thread-a"); diff --git a/apps/web/src/workspaceBasenameLookup.ts b/apps/web/src/workspaceBasenameLookup.ts index f1959cdb8cb8..440e02ce47b9 100644 --- a/apps/web/src/workspaceBasenameLookup.ts +++ b/apps/web/src/workspaceBasenameLookup.ts @@ -34,6 +34,20 @@ function hasSegmentSuffix(path: string, suffix: string): boolean { return posix === suffix || posix.endsWith(`/${suffix}`); } +/** + * Whether a divergent index hit may override the literal chip path only after + * a disk check. The search index skips gitignored files, so its absence there + * is not proof a file does not exist: for slashed paths an index hit that + * names a different file must not hijack a real file at the literal path. + * Bare names keep the ranked-first hit with no disk check. + */ +export function needsLiteralWorkspaceFileCheck( + lookupPath: string, + match: string | null, +): boolean { + return match !== null && match !== lookupPath && lookupPath.includes("/"); +} + export function pickWorkspaceBasenameMatch( basename: string, entries: ReadonlyArray, From 1263353278d9a5867c241cdec0770415260543d5 Mon Sep 17 00:00:00 2001 From: Chirag Bangera Date: Wed, 30 Sep 2026 23:12:16 -0400 Subject: [PATCH 12/13] fix(web): treat binary literal chip paths as existing readFile reports existing binary files with a distinct failure reason. Count binary_file (and path_not_file) as existing so a slashed chip path like docs/report.pdf keeps its literal file instead of resolving to an unrelated indexed suffix match. --- apps/web/src/components/ChatMarkdown.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b05e24e0f682..faa1d8f1c6a4 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -34,6 +34,7 @@ import type { ServerProviderSkill, ThreadPullRequestKey, } from "@t3tools/contracts"; +import { ProjectReadFileError } from "@t3tools/contracts"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; @@ -55,6 +56,7 @@ import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-li import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import * as Cause from "effect/Cause"; +import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/unstable/reactivity"; import React, { Children, @@ -279,6 +281,8 @@ export function shouldUseMarkdownFileBrowserPrimaryAction(input: { const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; const EMPTY_REMARK_PLUGINS: NonNullable = []; +const isProjectReadFileError = Schema.is(ProjectReadFileError); + const ARTIFACT_TEMPLATE_ICON_BY_KIND = { document: FileTextIcon, presentation: PresentationIcon, @@ -2569,10 +2573,17 @@ function useChatMarkdownState({ environmentId, input: { cwd, relativePath: workspaceRelativePath }, }); + if (result._tag === "Success") return true; // readFile reads from disk rather than the search index, so gitignored - // files still report present. Binary files fail the read and count as - // missing here; a stat RPC would close that gap. - return result._tag === "Success"; + // files still report present. Existing binary files (and directories) + // fail the read with a distinct reason but still prove the literal path + // exists, so the index must not hijack them. Only a genuinely missing + // file falls through to the indexed match. + const cause = squashAtomCommandFailure(result); + return ( + isProjectReadFileError(cause) && + (cause.failure === "binary_file" || cause.failure === "path_not_file") + ); }, [cwd, environmentId, readWorkspaceFile], ); From 81c533c743ef07f0321cea7f0004fd6ae272c435 Mon Sep 17 00:00:00 2001 From: Chirag Bangera Date: Wed, 30 Sep 2026 23:21:45 -0400 Subject: [PATCH 13/13] fix(web): refresh literal chip-path existence checks readFile caches for 30s; a stale cached failure/success would let the index hijack (or spare) the wrong file. Force a fresh read for this verification flow. --- apps/web/src/components/ChatMarkdown.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index faa1d8f1c6a4..695c574ce603 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -2329,6 +2329,7 @@ function useChatMarkdownState({ }); const readWorkspaceFile = useAtomQueryRunner(projectEnvironment.readFile, { reportFailure: false, + refresh: true, }); const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false,