Open assistant links to non-ASCII filenames (#6520)

* fix(app): decode percent-encoded assistant file links

markdown-it percent-encodes non-ASCII and space characters in link
hrefs, so a link like [x](docs/reports/报告.md) arrives as
docs/reports/%E6%8A%A5%E5%91%8A.md. The file:// and bare absolute-path
branches already run safeDecodeURIComponent, but the workspace-relative,
path:line, Windows, and home-relative branches did not, so clicking the
link tried to open the still-encoded name and failed with ENOENT.

Decode the path portion before the relative-path heuristics and in the
inline absolute/Windows branches so all href forms behave the same.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(app): decode hrefs only, never raw inline-code paths

Raw inline-code and linkified tokens reach the same parser as markdown
hrefs. Decoding unconditionally would send a real file named
my%20file.md to my file.md. Gate every decode on AssistantHrefParseOptions.decodeHref — set by the resolver only when the token
came from source.href — and extract parseWindowsFileLink to keep the
entry point under the complexity limit. Adds parse-level and
resolver-level regression tests for literal %20 filenames.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(app): keep raw absolute paths out of URL parsing

new URL() percent-encodes non-ASCII, so a raw inline-code path like
/tmp/开户.md became /tmp/%E5%BC%80%E6%88%B7.md when decodeHref was
false. Hrefs still go through URL parsing (normalizes dot segments
before decode); raw paths split the # fragment manually instead.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(app): keep encoded and literal file links distinct

Preserve inline-code provenance for Windows paths and key cached file lookups by their resolved query, path, and line range. Reproduce the reported non-ASCII Markdown link through the renderer and filesystem, with browser coverage for clicking it.

Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com>

Co-authored-by: dengjiawen <dengjiawen@hortorgames.com>

* test(app): assert file content after opening the markdown link

Co-authored-by: sunshinehomeyu-blip <267551119+sunshinehomeyu-blip@users.noreply.github.com>

* fix(app): preserve encoded line markers in filenames

---------

Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: dengjiawen <dengjiawen@hortorgames.com>
Co-authored-by: sunshinehomeyu-blip <267551119+sunshinehomeyu-blip@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-10 22:00:00 +02:00
committed by GitHub
co-authored by Devin Ouyang Xingyuan dengjiawen sunshinehomeyu-blip
parent 01f9bc8291
commit 563c014dc1
6 changed files with 444 additions and 48 deletions
@@ -96,6 +96,64 @@ async function seedAgentWithFileLink(input: LinkedFile) {
}
test.describe("CodeMirror workspace file editing", () => {
test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "encoded-markdown-link-",
title: "Non-ASCII file link",
initialPrompt: "Show the report link",
featureValues: { mockAssistantResponse: "[报告](docs/reports/开户赠金.md)" },
});
try {
await mkdir(path.join(session.cwd, "docs/reports"), { recursive: true });
await writeFile(path.join(session.cwd, "docs/reports/开户赠金.md"), "# Report opened\n");
await openAgentRoute(page, session);
await openAssistantMarkdownLink(page, "报告");
await expectFileTabOpen(page, "docs/reports/开户赠金.md");
await expect(
page
.getByTestId("workspace-file-pane")
.filter({ visible: true })
.getByText("Report opened", { exact: true }),
).toBeVisible();
await page.screenshot({ path: test.info().outputPath("non-ascii-file-opened.png") });
} finally {
await session.cleanup();
}
});
for (const order of ["raw-first", "href-first"] as const) {
test(`opens literal and encoded percent filenames independently ${order}`, async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "percent-link-cache-",
title: "Distinct file links",
initialPrompt: "Show both filenames",
featureValues: {
mockAssistantResponse: "Raw `literal%20.md`. [Decoded](literal%20.md).",
},
});
try {
await writeFile(path.join(session.cwd, "literal%20.md"), "# Literal filename\n");
await writeFile(path.join(session.cwd, "literal .md"), "# Decoded filename\n");
await openAgentRoute(page, session);
const links = order === "raw-first" ? ["raw", "href"] : ["href", "raw"];
for (const link of links) {
await returnToAssistant(page, session.agentId);
if (link === "raw") {
await openAssistantInlinePath(page, "literal%20.md");
await expectFileTabOpen(page, "literal%20.md");
await expectPreviewText(page, "Literal filename");
} else {
await openAssistantMarkdownLink(page, "Decoded");
await expectFileTabOpen(page, "literal .md");
await expectPreviewText(page, "Decoded filename");
}
}
} finally {
await session.cleanup();
}
});
}
test("jumps to each relative reference to the same file", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "relative-reference-qa-",
@@ -757,3 +815,24 @@ test.describe("CodeMirror workspace file editing", () => {
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
});
});
async function openAssistantMarkdownLink(page: Page, name: string): Promise<void> {
await page.getByRole("link", { name, exact: true }).first().click();
}
async function returnToAssistant(page: Page, agentId: string): Promise<void> {
await page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).click();
}
async function openAssistantInlinePath(page: Page, token: string): Promise<void> {
await page.getByText(token, { exact: true }).click();
}
async function expectPreviewText(page: Page, text: string): Promise<void> {
await expect(
page
.getByTestId("workspace-file-pane")
.filter({ visible: true })
.getByText(text, { exact: true }),
).toBeVisible();
}
@@ -417,6 +417,128 @@ describe("parseAssistantFileLink", () => {
}
expect(() => parseFileProtocolUrl("file:///tmp/100% packet loss")).not.toThrow();
});
it("decodes percent-encoded relative markdown hrefs", () => {
// markdown-it percent-encodes non-ASCII characters in hrefs, so a link
// written as [x](docs/reports/开户赠金.md) arrives here percent-encoded.
expect(
parseAssistantFileLink("docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md", {
workspaceRoot: "/Users/test/project",
decodeHref: true,
}),
).toEqual({
raw: "docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md",
path: "/Users/test/project/docs/reports/开户赠金.md",
lineStart: undefined,
lineEnd: undefined,
});
});
it("decodes percent-encoded spaces in relative markdown hrefs", () => {
expect(
parseAssistantFileLink("docs/my%20file.md#L5", {
workspaceRoot: "/Users/test/project",
decodeHref: true,
}),
).toEqual({
raw: "docs/my%20file.md#L5",
path: "/Users/test/project/docs/my file.md",
lineStart: 5,
lineEnd: undefined,
});
});
it("decodes percent-encoded absolute hrefs with line suffixes", () => {
expect(
parseAssistantFileLink("/tmp/%E5%BC%80%E6%88%B7.md:3", {
workspaceRoot: "/Users/test/project",
decodeHref: true,
}),
).toEqual({
raw: "/tmp/%E5%BC%80%E6%88%B7.md:3",
path: "/tmp/开户.md",
lineStart: 3,
lineEnd: undefined,
});
});
it("decodes percent-encoded home-relative hrefs", () => {
expect(
parseAssistantFileLink("~/notes/%E5%BC%80%E6%88%B7.md", {
workspaceRoot: "/Users/test/project",
decodeHref: true,
}),
).toEqual({
raw: "~/notes/%E5%BC%80%E6%88%B7.md",
path: "~/notes/开户.md",
lineStart: undefined,
lineEnd: undefined,
});
});
it("decodes percent-encoded Windows hrefs", () => {
expect(
parseAssistantFileLink("C:/repo/my%20dir/app.tsx#L8", {
workspaceRoot: "C:/repo",
decodeHref: true,
}),
).toEqual({
raw: "C:/repo/my%20dir/app.tsx#L8",
path: "C:/repo/my dir/app.tsx",
lineStart: 8,
lineEnd: undefined,
});
});
it("keeps literal percent signs in raw paths that are not hrefs", () => {
// Inline-code and linkified tokens are raw file paths: a real file named
// "my%20file.md" must not be decoded into "my file.md".
expect(
parseAssistantFileLink("docs/my%20file.md", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
raw: "docs/my%20file.md",
path: "/Users/test/project/docs/my%20file.md",
lineStart: undefined,
lineEnd: undefined,
});
expect(
parseAssistantFileLink("/tmp/my%20file.md:3", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
raw: "/tmp/my%20file.md:3",
path: "/tmp/my%20file.md",
lineStart: 3,
lineEnd: undefined,
});
});
it("keeps non-ASCII characters in raw absolute paths", () => {
// `new URL` percent-encodes non-ASCII, so raw paths must not be routed
// through URL parsing — `/tmp/开户.md` must round-trip unchanged.
expect(
parseAssistantFileLink("/tmp/开户.md", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
raw: "/tmp/开户.md",
path: "/tmp/开户.md",
lineStart: undefined,
lineEnd: undefined,
});
expect(
parseAssistantFileLink("/tmp/开户.md#L4", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
raw: "/tmp/开户.md#L4",
path: "/tmp/开户.md",
lineStart: 4,
lineEnd: undefined,
});
});
});
describe("normalizeInlinePathTarget", () => {
@@ -469,3 +591,23 @@ describe("normalizeInlinePathTarget", () => {
});
});
});
describe("encoded filename line markers", () => {
it.each(["docs/report.md%3A12", "docs/report.md%2812%2C4%29"])(
"keeps encoded line syntax in the filename %s",
(href) => {
expect(
parseAssistantFileLink(href, {
workspaceRoot: "/workspace",
decodeHref: true,
}),
).toMatchObject({ path: `/workspace/${decodeURIComponent(href)}` });
expect(
parseAssistantFileLink(href, {
workspaceRoot: "/workspace",
decodeHref: true,
})?.lineStart,
).toBeUndefined();
},
);
});
+93 -33
View File
@@ -80,6 +80,13 @@ const ASSISTANT_FILE_EXTENSIONS = new Set([
export interface AssistantHrefParseOptions {
workspaceRoot?: string;
/**
* True only when `value` is a markdown href — the renderer percent-encodes
* non-ASCII and space characters, so the path must be decoded. Raw path
* text (inline code, linkified tokens) is not encoded; decoding it would
* send a real file named `my%20file.md` to `my file.md`.
*/
decodeHref?: boolean;
}
export type AssistantFileLinkClassification =
@@ -234,13 +241,20 @@ export function parseFileProtocolUrl(value: string): InlinePathTarget | null {
};
}
function parseAssistantInlinePathLink(value: string): InlinePathTarget | null {
function parseAssistantInlinePathLink(
value: string,
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
): InlinePathTarget | null {
const inlinePathTarget = parseInlinePathToken(value);
if (!inlinePathTarget) {
return null;
}
const normalizedPath = normalizePathToken(inlinePathTarget.path);
let pathPart = inlinePathTarget.path;
if (options.decodeHref) {
pathPart = safeDecodeURIComponent(pathPart);
}
const normalizedPath = normalizePathToken(pathPart);
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
return null;
}
@@ -277,7 +291,7 @@ export function classifyAssistantFileLink(
return null;
}
if (isAmbiguousWorkspaceCandidate(trimmed, target, options.workspaceRoot)) {
if (isAmbiguousWorkspaceCandidate(trimmed, target, options)) {
return {
kind: "ambiguousFileCandidate",
target,
@@ -308,32 +322,19 @@ export function parseAssistantFileLink(
return null;
}
const inlinePathTarget = parseAssistantInlinePathLink(trimmed);
const inlinePathTarget = parseAssistantInlinePathLink(trimmed, options);
if (inlinePathTarget) {
return inlinePathTarget;
}
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
if (windowsPathMatch) {
const normalizedPath = normalizePathToken(windowsPathMatch[1] ?? "");
if (!normalizedPath) {
return null;
}
const lines = parseLineFragment(windowsPathMatch[2] ?? "");
if (!lines) {
return null;
}
return {
raw: value,
path: normalizedPath,
...lines,
};
const windowsTarget = parseWindowsFileLink(trimmed, value, options);
if (windowsTarget) {
return windowsTarget;
}
const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, {
workspaceRoot: options.workspaceRoot,
decodeHref: options.decodeHref,
});
if (relativeTarget) {
return relativeTarget;
@@ -343,19 +344,33 @@ export function parseAssistantFileLink(
return null;
}
// Hrefs go through URL parsing so `%`-escapes and `.`/`..` segments
// normalize before decode. Raw paths must not: `new URL` percent-encodes
// non-ASCII characters, which would corrupt `/tmp/开户.md` into the
// still-encoded name.
let pathPart: string;
let hashPart: string;
if (options.decodeHref) {
let parsedUrl: URL;
try {
parsedUrl = new URL(trimmed, "http://paseo.invalid");
} catch {
return null;
}
pathPart = safeDecodeURIComponent(parsedUrl.pathname);
hashPart = parsedUrl.hash;
} else {
const hashIndex = trimmed.indexOf("#");
pathPart = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed;
hashPart = hashIndex >= 0 ? trimmed.slice(hashIndex) : "";
}
const normalizedPath = normalizePathToken(safeDecodeURIComponent(parsedUrl.pathname));
const normalizedPath = normalizePathToken(pathPart);
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
return null;
}
const lines = parseLineFragment(parsedUrl.hash);
const lines = parseLineFragment(hashPart);
if (!lines) {
return null;
}
@@ -386,11 +401,42 @@ export function isFileLookingAssistantToken(value: string): boolean {
return isPlausibleAssistantLocalPath(path);
}
function parseWindowsFileLink(
trimmed: string,
raw: string,
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
): InlinePathTarget | null {
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
if (!windowsPathMatch) {
return null;
}
let pathPart = windowsPathMatch[1] ?? "";
if (options.decodeHref) {
pathPart = safeDecodeURIComponent(pathPart);
}
const normalizedPath = normalizePathToken(pathPart);
if (!normalizedPath) {
return null;
}
const lines = parseLineFragment(windowsPathMatch[2] ?? "");
if (!lines) {
return null;
}
return {
raw,
path: normalizedPath,
...lines,
};
}
function parseWorkspaceRelativeFileLink(
value: string,
options: AssistantHrefParseOptions,
): InlinePathTarget | null {
const parsed = parseLocalPathParts(value);
const parsed = parseLocalPathParts(value, options);
if (!parsed || isAbsolutePath(parsed.path)) {
return null;
}
@@ -422,7 +468,11 @@ function parseWorkspaceRelativeFileLink(
function parseLocalPathParts(
value: string,
): { path: string; lines: Pick<InlinePathTarget, "lineStart" | "lineEnd"> } | null {
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
): {
path: string;
lines: Pick<InlinePathTarget, "lineStart" | "lineEnd">;
} | null {
const normalized = normalizePathToken(value);
if (!normalized || normalized.includes("?")) {
return null;
@@ -436,14 +486,19 @@ function parseLocalPathParts(
return null;
}
// Split literal line markers before decoding; encoded punctuation belongs
// to the filename. Raw inline-code/path text stays untouched.
const inlinePathTarget = parseInlinePathToken(beforeHash);
if (inlinePathTarget) {
if (!isPlausibleAssistantLocalPath(inlinePathTarget.path)) {
let pathPart = inlinePathTarget.path;
if (options.decodeHref) {
pathPart = safeDecodeURIComponent(pathPart);
}
if (!isPlausibleAssistantLocalPath(pathPart)) {
return null;
}
return {
path: inlinePathTarget.path,
path: pathPart,
lines: {
lineStart: inlinePathTarget.lineStart,
lineEnd: inlinePathTarget.lineEnd,
@@ -455,12 +510,17 @@ function parseLocalPathParts(
return null;
}
if (!isPlausibleAssistantLocalPath(beforeHash)) {
let decodedBeforeHash = beforeHash;
if (options.decodeHref) {
decodedBeforeHash = safeDecodeURIComponent(beforeHash);
}
const plausiblePath = parseInlinePathToken(decodedBeforeHash)?.path ?? decodedBeforeHash;
if (!isPlausibleAssistantLocalPath(plausiblePath)) {
return null;
}
return {
path: beforeHash,
path: decodedBeforeHash,
lines: fragmentLines,
};
}
@@ -545,14 +605,14 @@ function isExternalHref(value: string): boolean {
function isAmbiguousWorkspaceCandidate(
value: string,
target: InlinePathTarget,
workspaceRoot?: string,
options: AssistantHrefParseOptions = {},
): boolean {
const normalizedWorkspaceRoot = normalizePathInput(workspaceRoot);
const normalizedWorkspaceRoot = normalizePathInput(options.workspaceRoot);
if (!normalizedWorkspaceRoot || !isAllowedAbsolutePath(target.path, normalizedWorkspaceRoot)) {
return false;
}
const parsed = parseLocalPathParts(value);
const parsed = parseLocalPathParts(value, options);
if (!parsed || isAbsolutePath(parsed.path)) {
return false;
}
@@ -1,4 +1,8 @@
import { describe, expect, it } from "vitest";
import MarkdownIt from "markdown-it";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
classifyForResolution,
fetchDaemonResolution,
@@ -14,6 +18,25 @@ const CONTEXT: AssistantFileLinkContext = {
workspaceRoot: "/Users/test/project",
};
it("opens the existing non-ASCII file named by a rendered markdown link", async () => {
const workspaceRoot = await mkdtemp(path.join(tmpdir(), "assistant-file-link-"));
try {
await mkdir(path.join(workspaceRoot, "docs/reports"), { recursive: true });
await writeFile(path.join(workspaceRoot, "docs/reports/开户赠金.md"), "report content");
const tokens = new MarkdownIt().parseInline("[报告](docs/reports/开户赠金.md)", {});
const href = tokens[0].children?.find((token) => token.type === "link_open")?.attrGet("href");
expect(href).toBe("docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md");
const resolution = classifyForResolution({ href: href! }, { workspaceRoot });
expect(resolution.kind).toBe("resolved");
if (resolution.kind !== "resolved" || resolution.value.kind !== "file") {
throw new Error("Expected a directly resolved file link");
}
expect(await readFile(resolution.value.target.path, "utf8")).toBe("report content");
} finally {
await rm(workspaceRoot, { recursive: true, force: true });
}
});
function resolvedSuggestions(
entries: DirectorySuggestionResult["entries"],
): DirectorySuggestionResult {
@@ -52,6 +75,27 @@ const unavailableSuggestions: GetDirectorySuggestions = async () => {
};
describe("classifyForResolution", () => {
it.each([
["C:/repo/my%20file.md", undefined],
["C:\\repo\\my%20file.md", undefined],
["C:/repo/my%20file.md:12", 12],
["C:\\repo\\my%20file.md:12", 12],
])("keeps the literal filename in Windows inline code %s", (href, lineStart) => {
for (const text of [href, undefined]) {
const result = classifyForResolution(
{ href, text, sourceType: "inline-code" },
{ workspaceRoot: "C:/repo" },
);
expect(result).toMatchObject({
kind: "resolved",
value: { kind: "file", target: { path: "C:/repo/my%20file.md", lineStart } },
});
}
expect(classifyForResolution({ href }, { workspaceRoot: "C:/repo" })).toMatchObject({
kind: "resolved",
value: { kind: "file", target: { path: "C:/repo/my file.md", lineStart } },
});
});
it("returns the directFile target synchronously", () => {
const result = classifyForResolution({ href: "src/components/message.tsx#L33" }, CONTEXT);
@@ -165,6 +209,46 @@ describe("classifyForResolution", () => {
expect(result).toEqual({ kind: "resolved", value: { kind: "ignored" } });
});
it("decodes percent-encoded markdown hrefs", () => {
const result = classifyForResolution({ href: "docs/my%20file.md" }, CONTEXT);
expect(result).toEqual({
kind: "resolved",
value: {
kind: "file",
target: {
raw: "docs/my%20file.md",
path: "/Users/test/project/docs/my file.md",
lineStart: undefined,
lineEnd: undefined,
},
},
});
});
it("keeps literal percent signs in inline-code paths", () => {
const result = classifyForResolution(
{
href: "docs/my%20file.md",
text: "docs/my%20file.md",
sourceType: "inline-code",
},
CONTEXT,
);
expect(result).toEqual({
kind: "needsLookup",
ambiguousQuery: "docs/my%20file.md",
token: "docs/my%20file.md",
target: {
raw: "docs/my%20file.md",
path: "/Users/test/project/docs/my%20file.md",
lineStart: undefined,
lineEnd: undefined,
},
});
});
});
describe("fetchDaemonResolution", () => {
@@ -108,13 +108,15 @@ export function classifyForResolution(
source: AssistantFileLinkSource,
context: AssistantFileLinkContext,
): AssistantFileLinkResolution {
const token = getAssistantFileLinkToken(source).trim();
const tokenInfo = getAssistantFileLinkTokenInfo(source);
const token = tokenInfo.token.trim();
if (!token) {
return { kind: "resolved", value: { kind: "ignored" } };
}
const classification = classifyAssistantFileLink(token, {
workspaceRoot: context.workspaceRoot,
decodeHref: tokenInfo.fromHref,
});
if (!classification) {
return { kind: "resolved", value: { kind: "ignored" } };
@@ -148,14 +150,26 @@ export function classifyForResolution(
}
export function getAssistantFileLinkToken(source: AssistantFileLinkSource): string {
if (isLinkifiedSource(source) || source.sourceType === "inline-code") {
return getAssistantFileLinkTokenInfo(source).token;
}
// Markdown link hrefs are percent-encoded by the renderer; inline-code and
// linkified text are raw file paths that must not be decoded.
function getAssistantFileLinkTokenInfo(source: AssistantFileLinkSource): {
token: string;
fromHref: boolean;
} {
if (source.sourceType === "inline-code") {
return { token: source.text?.trim() || source.href, fromHref: false };
}
if (isLinkifiedSource(source)) {
const text = source.text?.trim();
if (text && isFileLookingAssistantToken(text)) {
return text;
return { token: text, fromHref: false };
}
}
return source.href;
return { token: source.href, fromHref: true };
}
export function getAmbiguousSuggestionQuery(
@@ -29,14 +29,21 @@ export interface AssistantFileLinkActions {
canResolveFile(source: AssistantFileLinkSource): boolean;
}
interface AssistantFileLinkLookupKey {
query: string;
path: string;
lineStart: number | null;
lineEnd: number | null;
}
type AssistantFileLinkQueryKey = readonly [
"assistantFileLink",
string | null,
string | null,
string,
AssistantFileLinkLookupKey | null,
];
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, ""] as const;
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, null] as const;
export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult {
const { t } = useTranslation();
@@ -59,7 +66,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
? assistantFileLinkQueryKey({
serverId,
workspaceRoot,
token: resolution.token,
resolution,
})
: DISABLED_QUERY_KEY,
[resolution, serverId, workspaceRoot],
@@ -179,7 +186,7 @@ function openAssistantFileLink(input: {
const capturedQueryKey = assistantFileLinkQueryKey({
serverId: capturedConfig.serverId,
workspaceRoot: capturedConfig.workspaceRoot,
token: capturedResolution.token,
resolution: capturedResolution,
});
const run = async () => {
@@ -245,9 +252,19 @@ function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSour
function assistantFileLinkQueryKey(input: {
serverId?: string;
workspaceRoot?: string;
token: string;
resolution: Extract<AssistantFileLinkResolution, { kind: "needsLookup" }>;
}): AssistantFileLinkQueryKey {
return ["assistantFileLink", input.serverId ?? null, input.workspaceRoot ?? null, input.token];
return [
"assistantFileLink",
input.serverId ?? null,
input.workspaceRoot ?? null,
{
query: input.resolution.ambiguousQuery,
path: input.resolution.target.path,
lineStart: input.resolution.target.lineStart ?? null,
lineEnd: input.resolution.target.lineEnd ?? null,
},
];
}
async function dispatchResolvedLink(input: {