authorgravatar for rodrigobrechard@gmail.comRodrigo Brechard <rodrigobrechard@gmail.com> 2026-08-15 12:44:51+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 12:44:51+02:00
log2cb1a26f061fa9029ccbe2a614f02bb14b22dd45
treed454987ee24ba58faeba64aee2fa1db642dc382f
parentb277cc65e045899e7aa941e92d04f2b4996f27bb
signature Signed by PGP key B5690EEEBB952194

fix(web): open the file a bare filename reference names (#6297)

Co-authored-by: Rodrigo Brechard <rodrigo@clubtidy.fr> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

3 files changed, 192 insertions(+), 2 deletions(-)

apps/web/src/components/ChatMarkdown.tsx+51-2
...@@ -90,6 +90,13 @@ import { usePreparedConnection } from "../state/session";...@@ -90,6 +90,13 @@ import { usePreparedConnection } from "../state/session";
90import { previewEnvironment } from "../state/preview";90import { previewEnvironment } from "../state/preview";
91import { useAtomCommand } from "../state/use-atom-command";91import { useAtomCommand } from "../state/use-atom-command";
92import { useAtomQueryRunner } from "../state/use-atom-query-runner";92import { useAtomQueryRunner } from "../state/use-atom-query-runner";
93import { projectEnvironment } from "../state/projects";
94import {
95 claimWorkspaceBasenameLookup,
96 needsWorkspaceBasenameLookup,
97 pickWorkspaceBasenameMatch,
98 WORKSPACE_BASENAME_LOOKUP_LIMIT,
99} from "../workspaceBasenameLookup";
93import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink";100import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink";
94import { writeTextToClipboard } from "../hooks/useCopyToClipboard";101import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
95import { isPreviewSupportedInRuntime } from "../previewStateStore";102import { isPreviewSupportedInRuntime } from "../previewStateStore";
...@@ -811,6 +818,7 @@ interface MarkdownFileLinkProps {...@@ -811,6 +818,7 @@ interface MarkdownFileLinkProps {
811 theme: "light" | "dark";818 theme: "light" | "dark";
812 threadRef?: ScopedThreadRef | undefined;819 threadRef?: ScopedThreadRef | undefined;
813 onOpen: (targetPath: string) => Promise<AtomCommandResult<unknown, unknown>>;820 onOpen: (targetPath: string) => Promise<AtomCommandResult<unknown, unknown>>;
821 onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void;
814 onOpenInBrowser?: (() => Promise<AtomCommandResult<unknown, unknown>>) | undefined;822 onOpenInBrowser?: (() => Promise<AtomCommandResult<unknown, unknown>>) | undefined;
815 className?: string | undefined;823 className?: string | undefined;
816}824}
...@@ -1116,6 +1124,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -1116,6 +1124,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
1116 theme,1124 theme,
1117 threadRef,1125 threadRef,
1118 onOpen,1126 onOpen,
1127 onOpenInPanel,
1119 onOpenInBrowser,1128 onOpenInBrowser,
1120 className,1129 className,
1121}: MarkdownFileLinkProps) {1130}: MarkdownFileLinkProps) {
...@@ -1159,8 +1168,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({...@@ -1159,8 +1168,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
1159 handleOpenInEditor();1168 handleOpenInEditor();
1160 return;1169 return;
1161 }1170 }
1162 useRightPanelStore.getState().openFile(threadRef, workspaceRelativePath, line);1171 onOpenInPanel(workspaceRelativePath, line);
1163 }, [handleOpenInEditor, line, threadRef, workspaceRelativePath]);1172 }, [handleOpenInEditor, line, onOpenInPanel, threadRef, workspaceRelativePath]);
11641173
1165 const handleOpenInBrowser = useCallback(() => {1174 const handleOpenInBrowser = useCallback(() => {
1166 if (!onOpenInBrowser) {1175 if (!onOpenInBrowser) {
...@@ -1336,6 +1345,7 @@ function areMarkdownFileLinkPropsEqual(...@@ -1336,6 +1345,7 @@ function areMarkdownFileLinkPropsEqual(
1336 previous.theme === next.theme &&1345 previous.theme === next.theme &&
1337 previous.threadRef === next.threadRef &&1346 previous.threadRef === next.threadRef &&
1338 previous.onOpen === next.onOpen &&1347 previous.onOpen === next.onOpen &&
1348 previous.onOpenInPanel === next.onOpenInPanel &&
1339 previous.onOpenInBrowser === next.onOpenInBrowser &&1349 previous.onOpenInBrowser === next.onOpenInBrowser &&
1340 previous.className === next.className1350 previous.className === next.className
1341 );1351 );
...@@ -1355,6 +1365,9 @@ function ChatMarkdown({...@@ -1355,6 +1365,9 @@ function ChatMarkdown({
1355 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {1365 const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
1356 reportFailure: false,1366 reportFailure: false,
1357 });1367 });
1368 const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, {
1369 reportFailure: false,
1370 });
1358 const openPreview = useAtomCommand(previewEnvironment.open, {1371 const openPreview = useAtomCommand(previewEnvironment.open, {
1359 reportFailure: false,1372 reportFailure: false,
1360 });1373 });
...@@ -1457,6 +1470,40 @@ function ChatMarkdown({...@@ -1457,6 +1470,40 @@ function ChatMarkdown({
1457 },1470 },
1458 [createAssetUrl, openPreview, preparedConnection, threadRef],1471 [createAssetUrl, openPreview, preparedConnection, threadRef],
1459 );1472 );
1473 // A bare filename resolves to the workspace root, which is rarely where the
1474 // file is, so ask the index before opening.
1475 const openFileInPanel = useCallback(
1476 (workspaceRelativePath: string, line: number | undefined) => {
1477 if (!threadRef) return;
1478 // Claimed on every open so a synchronous one supersedes a lookup already
1479 // in flight.
1480 const isLatestLookup = claimWorkspaceBasenameLookup();
1481 const openAt = (path: string) =>
1482 useRightPanelStore.getState().openFile(threadRef, path, line);
1483 if (!cwd || !needsWorkspaceBasenameLookup(workspaceRelativePath)) {
1484 openAt(workspaceRelativePath);
1485 return;
1486 }
1487 void (async () => {
1488 const result = await searchProjectEntries({
1489 environmentId: threadRef.environmentId,
1490 input: {
1491 cwd,
1492 query: workspaceRelativePath,
1493 limit: WORKSPACE_BASENAME_LOOKUP_LIMIT,
1494 kind: "file",
1495 },
1496 });
1497 const match =
1498 result._tag === "Success"
1499 ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries)
1500 : null;
1501 if (!isLatestLookup()) return;
1502 openAt(match ?? workspaceRelativePath);
1503 })();
1504 },
1505 [cwd, searchProjectEntries, threadRef],
1506 );
1460 /* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component1507 /* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component
1461 * renderers that close over this message's metadata. useMemo keeps them stable until that1508 * renderers that close over this message's metadata. useMemo keeps them stable until that
1462 * metadata changes. */1509 * metadata changes. */
...@@ -1490,6 +1537,7 @@ function ChatMarkdown({...@@ -1490,6 +1537,7 @@ function ChatMarkdown({
1490 theme={resolvedTheme}1537 theme={resolvedTheme}
1491 threadRef={threadRef}1538 threadRef={threadRef}
1492 onOpen={openInPreferredEditor}1539 onOpen={openInPreferredEditor}
1540 onOpenInPanel={openFileInPanel}
1493 onOpenInBrowser={1541 onOpenInBrowser={
1494 threadRef &&1542 threadRef &&
1495 isPreviewSupportedInRuntime() &&1543 isPreviewSupportedInRuntime() &&
...@@ -1718,6 +1766,7 @@ function ChatMarkdown({...@@ -1718,6 +1766,7 @@ function ChatMarkdown({
1718 isStreaming,1766 isStreaming,
1719 markdownFileLinkMetaByHref,1767 markdownFileLinkMetaByHref,
1720 onTaskListChange,1768 onTaskListChange,
1769 openFileInPanel,
1721 openInPreferredEditor,1770 openInPreferredEditor,
1722 openExternalLinkInPreview,1771 openExternalLinkInPreview,
1723 openMarkdownFileInPreview,1772 openMarkdownFileInPreview,
apps/web/src/workspaceBasenameLookup.test.ts created+93
...@@ -0,0 +1,93 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 claimWorkspaceBasenameLookup,
5 needsWorkspaceBasenameLookup,
6 pickWorkspaceBasenameMatch,
7} from "./workspaceBasenameLookup";
8
9describe("needsWorkspaceBasenameLookup", () => {
10 it("flags bare filenames", () => {
11 expect(needsWorkspaceBasenameLookup("ChatView.tsx")).toBe(true);
12 expect(needsWorkspaceBasenameLookup("Makefile")).toBe(true);
13 });
14
15 it("leaves anything with a directory alone", () => {
16 expect(needsWorkspaceBasenameLookup("apps/web/src/components/ChatView.tsx")).toBe(false);
17 expect(needsWorkspaceBasenameLookup("apps\\web\\ChatView.tsx")).toBe(false);
18 expect(needsWorkspaceBasenameLookup(" ")).toBe(false);
19 });
20});
21
22describe("pickWorkspaceBasenameMatch", () => {
23 const entries = [
24 { path: "apps/web/src/components/ChatView.test.tsx", kind: "file" as const },
25 { path: "apps/web/src/components/ChatView.tsx", kind: "file" as const },
26 ];
27
28 it("takes the first exact filename match, not the closest fuzzy one", () => {
29 expect(pickWorkspaceBasenameMatch("ChatView.tsx", entries)).toBe(
30 "apps/web/src/components/ChatView.tsx",
31 );
32 });
33
34 it("ignores directories", () => {
35 expect(
36 pickWorkspaceBasenameMatch("components", [
37 { path: "apps/web/src/components", kind: "directory" },
38 { path: "apps/web/src/components/components", kind: "file" },
39 ]),
40 ).toBe("apps/web/src/components/components");
41 });
42
43 it("prefers the exactly-cased file over a case-only twin", () => {
44 expect(
45 pickWorkspaceBasenameMatch("foo.ts", [
46 { path: "src/Foo.ts", kind: "file" },
47 { path: "src/foo.ts", kind: "file" },
48 ]),
49 ).toBe("src/foo.ts");
50 });
51
52 it("falls back to case-insensitive when only the casing differs", () => {
53 expect(pickWorkspaceBasenameMatch("chatview.tsx", entries)).toBe(
54 "apps/web/src/components/ChatView.tsx",
55 );
56 });
57
58 it("returns null when the case-insensitive fallback is ambiguous", () => {
59 expect(
60 pickWorkspaceBasenameMatch("FOO.ts", [
61 { path: "src/Foo.ts", kind: "file" },
62 { path: "src/foo.ts", kind: "file" },
63 ]),
64 ).toBeNull();
65 });
66
67 it("returns null when nothing matches the name", () => {
68 expect(pickWorkspaceBasenameMatch("ChatView.tsx", [])).toBeNull();
69 expect(
70 pickWorkspaceBasenameMatch("ChatView.tsx", [
71 { path: "apps/web/src/components/ChatHeader.tsx", kind: "file" },
72 ]),
73 ).toBeNull();
74 });
75});
76
77describe("claimWorkspaceBasenameLookup", () => {
78 it("keeps only the newest claim, whatever order the lookups settle in", () => {
79 const first = claimWorkspaceBasenameLookup();
80 const second = claimWorkspaceBasenameLookup();
81
82 // The older lookup answering last must not reopen the panel behind the
83 // newer one.
84 expect(second()).toBe(true);
85 expect(first()).toBe(false);
86 });
87
88 it("stays valid while it is the only claim", () => {
89 const only = claimWorkspaceBasenameLookup();
90 expect(only()).toBe(true);
91 expect(only()).toBe(true);
92 });
93});
apps/web/src/workspaceBasenameLookup.ts created+48
...@@ -0,0 +1,48 @@
1// Enough hits to look past same-named neighbours (`ChatView.test.tsx`) without
2// asking for a full listing on a single click.
3export const WORKSPACE_BASENAME_LOOKUP_LIMIT = 25;
4
5// One counter for every caller: they all open the same panel, so the newest
6// click wins regardless of which one started the lookup.
7let latestLookupSequence = 0;
8
9/** Call the returned predicate when the search settles; false means a later click superseded it. */
10export function claimWorkspaceBasenameLookup(): () => boolean {
11 latestLookupSequence += 1;
12 const claimed = latestLookupSequence;
13 return () => claimed === latestLookupSequence;
14}
15
16export interface WorkspaceEntryCandidate {
17 readonly path: string;
18 readonly kind: "file" | "directory";
19}
20
21function basenameOfPath(path: string): string {
22 const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
23 return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path;
24}
25
26export function needsWorkspaceBasenameLookup(relativePath: string): boolean {
27 const trimmed = relativePath.trim();
28 return trimmed.length > 0 && !trimmed.includes("/") && !trimmed.includes("\\");
29}
30
31export function pickWorkspaceBasenameMatch(
32 basename: string,
33 entries: ReadonlyArray<WorkspaceEntryCandidate>,
34): string | null {
35 const target = basename.trim();
36 if (!target) return null;
37 const files = entries.filter((entry) => entry.kind === "file");
38 const exact = files.find((entry) => basenameOfPath(entry.path) === target);
39 if (exact) return exact.path;
40 // Folded matching covers casing that drifted from disk, but `FOO.ts` against
41 // both `Foo.ts` and `foo.ts` has no right answer, so it resolves to nothing
42 // rather than opening whichever the index ranked first.
43 const folded = target.toLowerCase();
44 const foldedMatches = files.filter(
45 (entry) => basenameOfPath(entry.path).toLowerCase() === folded,
46 );
47 return foldedMatches.length === 1 ? (foldedMatches[0]?.path ?? null) : null;
48}