Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3527,10 +3527,14 @@ export default function App({
}, []);

const onOpenFile = useCallback<OpenFileFn>(
(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;
Expand Down
21 changes: 21 additions & 0 deletions src/lib/fileIndex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
45 changes: 41 additions & 4 deletions src/lib/fileIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,19 +185,27 @@ export function rankProjectFiles(
export async function resolveOpenablePath(
cwd: string,
href: string,
candidatePaths: readonly string[] = [],
): Promise<string | undefined> {
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 {
files = await loadProjectFiles(cwd);
} 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]),
Expand All @@ -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 ||
Expand All @@ -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<string, string>();
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(
Expand Down
11 changes: 10 additions & 1 deletion src/lib/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
25 changes: 22 additions & 3 deletions src/surfaces/AgentTranscript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -86,6 +87,7 @@ import {
nestedScrollAbsorbsWheel,
proseSummary,
subagentFailureSummary,
transcriptFilePaths,
toolCallLabel,
toolCallState,
turnCopyText,
Expand All @@ -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;
Expand All @@ -139,7 +141,7 @@ function AgentTranscriptComponent({
onAddToChat,
onSaveNote,
onSaveSelectionNote,
onOpenFile,
onOpenFile: onOpenFileProp,
onOpenDiff,
onOpenPlan,
onBuildPlan,
Expand Down Expand Up @@ -184,6 +186,23 @@ function AgentTranscriptComponent({
const currentModelName = harness
? resolveModel(harness, model).name
: undefined;
const filePaths = useMemo(() => transcriptFilePaths(blocks), [blocks]);
const handleOpenFile = useCallback<OpenFileFn>(
(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) =>
Expand Down
62 changes: 62 additions & 0 deletions src/surfaces/AgentTranscriptFileLinks.test.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>('code[role="link"]')!.click();
});

expect(onOpenFile).toHaveBeenCalledWith(
"/Users/me/session/backup.yaml",
undefined,
{ candidatePaths: [path] },
);
});
});
3 changes: 2 additions & 1 deletion src/surfaces/SessionPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 },
Expand Down
14 changes: 14 additions & 0 deletions src/surfaces/transcriptActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
nestedScrollAbsorbsWheel,
proseSummary,
subagentFailureSummary,
transcriptFilePaths,
toolCallLabel,
turnCopyText,
} from "./transcriptActivity";
Expand Down Expand Up @@ -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([
Expand Down
10 changes: 10 additions & 0 deletions src/surfaces/transcriptActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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;
}
Expand Down
Loading