From e9f300cf17465c1146b657c28c408f7b0e500934 Mon Sep 17 00:00:00 2001 From: Yan Bond Date: Sat, 12 Sep 2026 00:17:11 +0300 Subject: [PATCH] Resolve shortened transcript file paths --- src/App.tsx | 8 ++- src/lib/fileIndex.test.ts | 21 +++++++ src/lib/fileIndex.ts | 45 ++++++++++++-- src/lib/search.ts | 11 +++- src/surfaces/AgentTranscript.tsx | 25 +++++++- src/surfaces/AgentTranscriptFileLinks.test.ts | 62 +++++++++++++++++++ src/surfaces/SessionPane.tsx | 3 +- src/surfaces/transcriptActivity.test.ts | 14 +++++ src/surfaces/transcriptActivity.ts | 10 +++ 9 files changed, 188 insertions(+), 11 deletions(-) create mode 100644 src/surfaces/AgentTranscriptFileLinks.test.ts diff --git a/src/App.tsx b/src/App.tsx index e2aba681..9de691fd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3527,10 +3527,14 @@ export default function App({ }, []); const onOpenFile = useCallback( - (path, navigation) => { + (path, navigation, hints) => { void (async () => { const resolved = - (await resolveOpenablePath(gitCwdRef.current, path)) ?? path; + (await resolveOpenablePath( + gitCwdRef.current, + path, + hints?.candidatePaths, + )) ?? path; rememberOpenedFile(sidebarCwdRef.current, resolved); const tab = tabsRef.current.find((entry) => entry.id === activeTabId); if (!tab) return; diff --git a/src/lib/fileIndex.test.ts b/src/lib/fileIndex.test.ts index 7eed985e..93e538f0 100644 --- a/src/lib/fileIndex.test.ts +++ b/src/lib/fileIndex.test.ts @@ -77,6 +77,27 @@ describe("resolveOpenablePath", () => { expect(resolved).toBe(files[2].path); }); + it("uses a unique tool path when a short link is outside the project cwd", async () => { + const path = "/Users/me/other/project/platform/backup.yaml"; + const resolved = await resolveOpenablePath(cwd, "backup.yaml", [path]); + expect(resolved).toBe(path); + }); + + it("does not guess between ambiguous tool paths", async () => { + const resolved = await resolveOpenablePath(cwd, "backup.yaml", [ + "/Users/me/one/backup.yaml", + "/Users/me/two/backup.yaml", + ]); + expect(resolved).toBe(`${cwd}/backup.yaml`); + }); + + it("prefers an indexed project file over a transcript path", async () => { + const resolved = await resolveOpenablePath(cwd, "App.tsx", [ + "/Users/me/other/App.tsx", + ]); + expect(resolved).toBe(files[1].path); + }); + it("still opens a direct file when the optional project index is unavailable", async () => { list.mockRejectedValue(new Error("Project scan unavailable")); await expect(resolveOpenablePath(cwd, "apps/desktop/src/main.tsx")) diff --git a/src/lib/fileIndex.ts b/src/lib/fileIndex.ts index c8067790..6b858796 100644 --- a/src/lib/fileIndex.ts +++ b/src/lib/fileIndex.ts @@ -185,9 +185,17 @@ export function rankProjectFiles( export async function resolveOpenablePath( cwd: string, href: string, + candidatePaths: readonly string[] = [], ): Promise { const direct = resolveWorkspacePath(href, cwd); if (!direct) return undefined; + const relHint = relativePathHint(href, cwd, direct); + const referenced = resolveReferencedPath( + candidatePaths, + cwd, + direct, + relHint, + ); let files: ProjectFile[]; try { @@ -195,9 +203,9 @@ export async function resolveOpenablePath( } catch { // The index only disambiguates shortened paths. Let the editor read the // direct path and show its own error if that file is unavailable too. - return direct; + return referenced ?? direct; } - if (files.length === 0) return direct; + if (files.length === 0) return referenced ?? direct; const byPath = new Map( files.map((file) => [normalizeEditorPath(file.path), file]), @@ -206,7 +214,6 @@ export async function resolveOpenablePath( const exact = byPath.get(normalizedDirect); if (exact) return exact.path; - const relHint = relativePathHint(href, cwd, direct); const exactRelative = files.find( (file) => file.relative === relHint || @@ -224,12 +231,42 @@ export async function resolveOpenablePath( const baseName = relHint.split("/").filter(Boolean).pop() ?? relHint; const byName = files.filter((file) => file.name === baseName); - if (byName.length === 0) return direct; + if (byName.length === 0) return referenced ?? direct; if (byName.length === 1) return byName[0].path; return pickOpenableFile(byName, cwd, relHint).path; } +function resolveReferencedPath( + paths: readonly string[], + cwd: string, + direct: string, + relHint: string, +): string | undefined { + const candidates = new Map(); + for (const path of paths) { + const resolved = resolveWorkspacePath(path, cwd); + if (!resolved) continue; + candidates.set(normalizeEditorPath(resolved), resolved); + } + + const exact = candidates.get(normalizeEditorPath(direct)); + if (exact) return exact; + + const normalizedHint = normalizeEditorPath(relHint); + const suffixMatches = [...candidates].filter(([normalized]) => + normalized.endsWith(`/${normalizedHint}`), + ); + if (suffixMatches.length === 1) return suffixMatches[0][1]; + + const baseName = normalizedHint.split("/").filter(Boolean).pop(); + if (!baseName) return undefined; + const nameMatches = [...candidates].filter( + ([normalized]) => normalized.split("/").pop() === baseName, + ); + return nameMatches.length === 1 ? nameMatches[0][1] : undefined; +} + function relativePathHint(href: string, cwd: string, direct: string): string { let value = href.trim().replace(/\\/g, "/"); value = value.replace( diff --git a/src/lib/search.ts b/src/lib/search.ts index a5a9dd13..67b4b5b6 100644 --- a/src/lib/search.ts +++ b/src/lib/search.ts @@ -34,7 +34,16 @@ export type EditorNavigationTarget = EditorNavigation & { token: number; }; -export type OpenFileFn = (path: string, navigation?: EditorNavigation) => void; +export type OpenFileHints = { + /** Exact file paths already surfaced by tools in the current transcript. */ + candidatePaths?: readonly string[]; +}; + +export type OpenFileFn = ( + path: string, + navigation?: EditorNavigation, + hints?: OpenFileHints, +) => void; export function normalizeEditorPath(path: string): string { return slash(path).replace(/\/+$/, "") || path; diff --git a/src/surfaces/AgentTranscript.tsx b/src/surfaces/AgentTranscript.tsx index 83f0dee8..45b6747e 100644 --- a/src/surfaces/AgentTranscript.tsx +++ b/src/surfaces/AgentTranscript.tsx @@ -46,8 +46,9 @@ import { import { copyText } from "../lib/clipboard"; import { playCue } from "../lib/sounds"; import { legacyTaskListFromText } from "../lib/taskList"; -import { displayPath, resolveWorkspacePath } from "../lib/paths"; +import { displayPath, pathKey, resolveWorkspacePath } from "../lib/paths"; import { resolveModel } from "../lib/models"; +import type { OpenFileFn } from "../lib/search"; import { harnessForTurn } from "../lib/secondOpinion"; import { Shimmer } from "./Shimmer"; import { @@ -86,6 +87,7 @@ import { nestedScrollAbsorbsWheel, proseSummary, subagentFailureSummary, + transcriptFilePaths, toolCallLabel, toolCallState, turnCopyText, @@ -112,7 +114,7 @@ type Props = { onAddToChat?: (text: string) => void; onSaveNote?: (text: string) => void; onSaveSelectionNote?: (text: string) => void; - onOpenFile?: (path: string) => void; + onOpenFile?: OpenFileFn; onOpenDiff?: (path: string) => void; onOpenPlan?: (blockId: string) => void; onBuildPlan?: (blockId: string, target?: PlanBuildTarget) => void; @@ -139,7 +141,7 @@ function AgentTranscriptComponent({ onAddToChat, onSaveNote, onSaveSelectionNote, - onOpenFile, + onOpenFile: onOpenFileProp, onOpenDiff, onOpenPlan, onBuildPlan, @@ -184,6 +186,23 @@ function AgentTranscriptComponent({ const currentModelName = harness ? resolveModel(harness, model).name : undefined; + const filePaths = useMemo(() => transcriptFilePaths(blocks), [blocks]); + const handleOpenFile = useCallback( + (path, navigation) => { + const exactToolPath = filePaths.some((candidate) => { + const resolved = resolveWorkspacePath(candidate, cwd); + return resolved ? pathKey(resolved) === pathKey(path) : false; + }); + if (filePaths.length === 0 || exactToolPath) { + if (navigation) onOpenFileProp?.(path, navigation); + else onOpenFileProp?.(path); + return; + } + onOpenFileProp?.(path, navigation, { candidatePaths: filePaths }); + }, + [cwd, filePaths, onOpenFileProp], + ); + const onOpenFile = onOpenFileProp ? handleOpenFile : undefined; const waitingForApproval = hasPendingApproval(blocks) || pendingQuestion; const preparingHandoff = blocks.some( (block) => diff --git a/src/surfaces/AgentTranscriptFileLinks.test.ts b/src/surfaces/AgentTranscriptFileLinks.test.ts new file mode 100644 index 00000000..3ccbcdaf --- /dev/null +++ b/src/surfaces/AgentTranscriptFileLinks.test.ts @@ -0,0 +1,62 @@ +// @vitest-environment happy-dom +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Block } from "../lib/session"; +import { AgentTranscript } from "./AgentTranscript"; + +describe("AgentTranscript file references", () => { + let root: Root; + let container: HTMLDivElement; + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + it("passes exact tool paths when opening a shortened prose reference", async () => { + const path = "/Users/me/other/project/platform/backup.yaml"; + const blocks: Block[] = [ + { id: "user", role: "user", text: "Inspect the backup" }, + { + id: "edit", + role: "tool", + text: `Edit ${path}`, + tool: { + kind: "edit", + status: "completed", + preview: { kind: "write", path, fileName: "backup.yaml" }, + }, + }, + { id: "answer", role: "assistant", text: "Updated `backup.yaml`." }, + ]; + const onOpenFile = vi.fn(); + + await act(async () => { + root.render( + createElement(AgentTranscript, { + blocks, + cwd: "/Users/me/session", + onOpenFile, + }), + ); + }); + await act(async () => { + container.querySelector('code[role="link"]')!.click(); + }); + + expect(onOpenFile).toHaveBeenCalledWith( + "/Users/me/session/backup.yaml", + undefined, + { candidatePaths: [path] }, + ); + }); +}); diff --git a/src/surfaces/SessionPane.tsx b/src/surfaces/SessionPane.tsx index 5c798a4b..20450475 100644 --- a/src/surfaces/SessionPane.tsx +++ b/src/surfaces/SessionPane.tsx @@ -56,6 +56,7 @@ import { subscribeChatBackgroundPath, } from "../lib/appearance"; import type { SessionFolderTarget } from "../lib/sessionFolders"; +import type { OpenFileFn } from "../lib/search"; type Props = { session: Session; @@ -112,7 +113,7 @@ type Props = { reply: UserQuestionReply, ) => void; onQuestionInteraction?: (sessionId: string, requestId: number) => void; - onOpenFile: (path: string) => void; + onOpenFile: OpenFileFn; onOpenDiff: ( path?: string, session?: { sessionId: string; cwd: string }, diff --git a/src/surfaces/transcriptActivity.test.ts b/src/surfaces/transcriptActivity.test.ts index a8cfcc88..31586bbe 100644 --- a/src/surfaces/transcriptActivity.test.ts +++ b/src/surfaces/transcriptActivity.test.ts @@ -16,6 +16,7 @@ import { nestedScrollAbsorbsWheel, proseSummary, subagentFailureSummary, + transcriptFilePaths, toolCallLabel, turnCopyText, } from "./transcriptActivity"; @@ -86,6 +87,19 @@ function thought(id: string, text = "Weighing the options."): Block { return { id, role: "reasoning", text }; } +describe("transcriptFilePaths", () => { + it("returns unique structured file paths without scraping prose", () => { + expect( + transcriptFilePaths([ + edit("edit", "/other/project/platform/backup.yaml"), + read("read", "/other/project/platform/backup.yaml"), + search("search"), + note("note", "/untrusted/prose/backup.yaml"), + ]), + ).toEqual(["/other/project/platform/backup.yaml"]); + }); +}); + describe("groupTurnItems", () => { it("keeps consecutive shell calls in one activity stack", () => { const items = groupTurnItems([ diff --git a/src/surfaces/transcriptActivity.ts b/src/surfaces/transcriptActivity.ts index 14774e31..5785e003 100644 --- a/src/surfaces/transcriptActivity.ts +++ b/src/surfaces/transcriptActivity.ts @@ -16,6 +16,16 @@ export type ToolCallState = "pending" | "accepted" | "rejected"; export type TurnItem = { type: "block"; block: Block } | { type: "activity"; blocks: Block[] }; +/** File paths backed by structured tool events in this transcript. */ +export function transcriptFilePaths(blocks: readonly Block[]): string[] { + const paths = new Set(); + for (const block of blocks) { + const path = block.tool?.preview?.path?.trim(); + if (path) paths.add(path); + } + return [...paths]; +} + export function needsApproval(block: Block): boolean { return !!block.approval && !block.approval.decided; }