From 2c164eda6c2533d6eefe796e6597c93ca2335fe7 Mon Sep 17 00:00:00 2001 From: fancivez Date: Sat, 22 Aug 2026 09:38:02 +0800 Subject: [PATCH 1/5] fix(app): decode dropped file URLs --- .../components/prompt-input/attachments.ts | 16 ++++++++---- .../prompt-input/build-request-parts.test.ts | 25 +++++++++++++++++++ packages/app/src/context/file/path.test.ts | 23 ++++++++++++++++- packages/app/src/context/file/path.ts | 9 +++++++ 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index 6f3ef57c66e2..2f40016f9eef 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -1,7 +1,7 @@ import { onMount } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" import { showToast } from "@/utils/toast" -import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt" +import { type ContentPart, type FileAttachmentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { uuid } from "@/utils/uuid" @@ -9,6 +9,7 @@ import { getCursorPosition } from "./editor-dom" import { createBlobReference, type DraftStore } from "@/utils/draft-store" import { attachmentMime } from "./files" import { normalizePaste, pasteMode } from "./paste" +import { parseFileURL } from "@/context/file/path" type PromptTarget = Pick["capture"]>, "current" | "cursor" | "set"> type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined } @@ -35,6 +36,12 @@ export type PromptAttachmentsInput = { getPathForFile?: (file: File) => string } +export function filePartFromFileURL(input: string): FileAttachmentPart | undefined { + const file = parseFileURL(input) + if (!file) return undefined + return { type: "file", path: file.path, url: file.url, content: "@" + file.path, start: 0, end: 0 } +} + export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) { const capture = (): AttachmentTarget | undefined => { const prompt = input.capture() @@ -189,11 +196,10 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { input.setDraggingType(null) const plainText = event.dataTransfer?.getData("text/plain") - const filePrefix = "file:" - if (plainText?.startsWith(filePrefix)) { - const filePath = plainText.slice(filePrefix.length) + const file = plainText ? filePartFromFileURL(plainText) : undefined + if (file) { input.focusEditor() - input.addPart({ type: "file", path: filePath, content: "@" + filePath, start: 0, end: 0 }) + input.addPart(file) return } diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts index ab84cb6eae81..7f050344e511 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.test.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import type { Prompt } from "@/context/prompt" +import { filePartFromFileURL } from "./attachments" import { buildRequestParts } from "./build-request-parts" describe("buildRequestParts", () => { @@ -258,6 +259,30 @@ describe("buildRequestParts", () => { } }) + test("preserves decoded paths and canonical URLs from desktop file drops", () => { + const attachment = filePartFromFileURL("file:///home/carole/Bureau/%3F%3F%3F.png") + if (!attachment) throw new Error("Expected a file attachment") + + const result = buildRequestParts({ + prompt: [attachment], + context: [], + images: [], + text: attachment.content, + messageID: "msg_drop_1", + sessionID: "ses_drop_1", + sessionDirectory: "/repo", + }) + + expect(result.requestParts.find((part) => part.type === "file")).toMatchObject({ + url: "file:///home/carole/Bureau/%3F%3F%3F.png", + source: { + type: "file", + path: "/home/carole/Bureau/???.png", + text: { value: "@/home/carole/Bureau/???.png" }, + }, + }) + }) + test("handles macOS paths correctly", () => { const prompt: Prompt = [{ type: "file", path: "README.md", content: "@README.md", start: 0, end: 9 }] diff --git a/packages/app/src/context/file/path.test.ts b/packages/app/src/context/file/path.test.ts index 99dd88ae0e9c..8f9c90a8f0b6 100644 --- a/packages/app/src/context/file/path.test.ts +++ b/packages/app/src/context/file/path.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { createPathHelpers, stripQueryAndHash, unquoteGitPath, encodeFilePath } from "./path" +import { createPathHelpers, stripQueryAndHash, unquoteGitPath, encodeFilePath, parseFileURL } from "./path" describe("file path helpers", () => { test("normalizes file inputs against workspace root", () => { @@ -57,6 +57,27 @@ describe("file path helpers", () => { }) }) +describe("parseFileURL", () => { + test("decodes reserved characters in POSIX file URLs", () => { + expect(parseFileURL("file:///home/carole/Bureau/%3F%3F%3F.png")).toEqual({ + path: "/home/carole/Bureau/???.png", + url: "file:///home/carole/Bureau/%3F%3F%3F.png", + }) + expect(parseFileURL("file:///tmp/file%2520name%23.txt")?.path).toBe("/tmp/file%20name#.txt") + }) + + test("preserves Windows drives and UNC hosts", () => { + expect(parseFileURL("file:///C:/Users/test/file%3F.txt")?.path).toBe("/C:/Users/test/file?.txt") + expect(parseFileURL("file://server/share/file%23.txt")?.path).toBe("//server/share/file#.txt") + }) + + test("accepts localhost and rejects non-file URLs", () => { + expect(parseFileURL("file://localhost/tmp/file.txt")?.path).toBe("/tmp/file.txt") + expect(parseFileURL("https://example.com/file.txt")).toBeUndefined() + expect(parseFileURL("not a URL")).toBeUndefined() + }) +}) + describe("encodeFilePath", () => { describe("Linux/Unix paths", () => { test("should handle Linux absolute path", () => { diff --git a/packages/app/src/context/file/path.ts b/packages/app/src/context/file/path.ts index 2bc4bde5e9b4..3180ac6d6dc5 100644 --- a/packages/app/src/context/file/path.ts +++ b/packages/app/src/context/file/path.ts @@ -80,6 +80,15 @@ export function decodeFilePath(input: string) { } } +export function parseFileURL(input: string) { + if (!URL.canParse(input)) return undefined + const url = new URL(input) + if (url.protocol !== "file:") return undefined + const pathname = decodeFilePath(url.pathname) + const path = !url.hostname || url.hostname === "localhost" ? pathname : `//${url.hostname}${pathname}` + return { path, url: url.href } +} + export function encodeFilePath(filepath: string): string { // Normalize Windows paths: convert backslashes to forward slashes let normalized = filepath.replace(/\\/g, "/") From 00bd8e2efbdc0979af3f7c4b413904689c440be0 Mon Sep 17 00:00:00 2001 From: fancivez Date: Sat, 22 Aug 2026 11:21:14 +0800 Subject: [PATCH 2/5] no-mistakes(review): guard URL.canParse availability in parseFileURL --- packages/app/src/context/file/path.test.ts | 15 +++++++++++++++ packages/app/src/context/file/path.ts | 9 +++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/app/src/context/file/path.test.ts b/packages/app/src/context/file/path.test.ts index 8f9c90a8f0b6..74bcbed0c689 100644 --- a/packages/app/src/context/file/path.test.ts +++ b/packages/app/src/context/file/path.test.ts @@ -76,6 +76,21 @@ describe("parseFileURL", () => { expect(parseFileURL("https://example.com/file.txt")).toBeUndefined() expect(parseFileURL("not a URL")).toBeUndefined() }) + + test("falls back when URL.canParse is unavailable", () => { + const original = Object.getOwnPropertyDescriptor(URL, "canParse") + Object.defineProperty(URL, "canParse", { configurable: true, value: undefined }) + try { + expect(parseFileURL("file:///home/carole/Bureau/%3F%3F%3F.png")).toEqual({ + path: "/home/carole/Bureau/???.png", + url: "file:///home/carole/Bureau/%3F%3F%3F.png", + }) + expect(parseFileURL("not a URL")).toBeUndefined() + } finally { + if (original) Object.defineProperty(URL, "canParse", original) + if (!original) Reflect.deleteProperty(URL, "canParse") + } + }) }) describe("encodeFilePath", () => { diff --git a/packages/app/src/context/file/path.ts b/packages/app/src/context/file/path.ts index 3180ac6d6dc5..1aee8db48c5b 100644 --- a/packages/app/src/context/file/path.ts +++ b/packages/app/src/context/file/path.ts @@ -81,8 +81,13 @@ export function decodeFilePath(input: string) { } export function parseFileURL(input: string) { - if (!URL.canParse(input)) return undefined - const url = new URL(input) + if (typeof URL.canParse === "function" && !URL.canParse(input)) return undefined + let url: URL + try { + url = new URL(input) + } catch { + return undefined + } if (url.protocol !== "file:") return undefined const pathname = decodeFilePath(url.pathname) const path = !url.hostname || url.hostname === "localhost" ? pathname : `//${url.hostname}${pathname}` From e41abacc7323eff5dde3526b5678c57868be86e1 Mon Sep 17 00:00:00 2001 From: fancivez Date: Sat, 22 Aug 2026 11:38:06 +0800 Subject: [PATCH 3/5] no-mistakes(review): fix(app): keep file-tree drags relative, URL-parse only file:// drops --- .../components/prompt-input/attachments.ts | 11 ++++-- .../prompt-input/build-request-parts.test.ts | 34 +++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index 2f40016f9eef..ea32099e4115 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -37,9 +37,14 @@ export type PromptAttachmentsInput = { } export function filePartFromFileURL(input: string): FileAttachmentPart | undefined { - const file = parseFileURL(input) - if (!file) return undefined - return { type: "file", path: file.path, url: file.url, content: "@" + file.path, start: 0, end: 0 } + if (!input.startsWith("file:")) return undefined + if (input.startsWith("file://")) { + const file = parseFileURL(input) + if (!file) return undefined + return { type: "file", path: file.path, url: file.url, content: "@" + file.path, start: 0, end: 0 } + } + const filePath = input.slice("file:".length) + return { type: "file", path: filePath, content: "@" + filePath, start: 0, end: 0 } } export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) { diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts index 7f050344e511..2aa22d91708d 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.test.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts @@ -283,6 +283,40 @@ describe("buildRequestParts", () => { }) }) + test("resolves relative paths from sidebar file-tree drags against sessionDirectory", () => { + const attachment = filePartFromFileURL("file:packages/app/src/foo.ts") + if (!attachment) throw new Error("Expected a file attachment") + + const result = buildRequestParts({ + prompt: [attachment], + context: [], + images: [], + text: attachment.content, + messageID: "msg_drop_2", + sessionID: "ses_drop_2", + sessionDirectory: "/repo", + }) + + expect(result.requestParts.find((part) => part.type === "file")).toMatchObject({ + url: "file:///repo/packages/app/src/foo.ts", + source: { + type: "file", + path: "/repo/packages/app/src/foo.ts", + text: { value: "@packages/app/src/foo.ts" }, + }, + }) + }) + + test("keeps reserved characters in relative file-tree drag paths", () => { + expect(filePartFromFileURL("file:packages/app/??.ts")).toEqual({ + type: "file", + path: "packages/app/??.ts", + content: "@packages/app/??.ts", + start: 0, + end: 0, + }) + }) + test("handles macOS paths correctly", () => { const prompt: Prompt = [{ type: "file", path: "README.md", content: "@README.md", start: 0, end: 9 }] From 93b9a8de38fc6c8079768532799d5f905da971ba Mon Sep 17 00:00:00 2001 From: fancivez Date: Sat, 22 Aug 2026 18:53:28 +0800 Subject: [PATCH 4/5] fix(app): handle alternate dropped file URLs --- .../app/src/components/prompt-input/attachments.ts | 4 ++-- .../prompt-input/build-request-parts.test.ts | 11 +++++++++++ packages/app/src/context/file/path.test.ts | 11 +++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index ea32099e4115..e13467bc8d21 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -37,8 +37,8 @@ export type PromptAttachmentsInput = { } export function filePartFromFileURL(input: string): FileAttachmentPart | undefined { - if (!input.startsWith("file:")) return undefined - if (input.startsWith("file://")) { + if (!/^file:/i.test(input)) return undefined + if (/^file:\//i.test(input)) { const file = parseFileURL(input) if (!file) return undefined return { type: "file", path: file.path, url: file.url, content: "@" + file.path, start: 0, end: 0 } diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts index 2aa22d91708d..a9d9da4f613f 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.test.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts @@ -317,6 +317,17 @@ describe("buildRequestParts", () => { }) }) + test("parses single-slash file URLs with case-insensitive schemes", () => { + expect(filePartFromFileURL("FILE:/tmp/file%3F.txt")).toEqual({ + type: "file", + path: "/tmp/file?.txt", + url: "file:///tmp/file%3F.txt", + content: "@/tmp/file?.txt", + start: 0, + end: 0, + }) + }) + test("handles macOS paths correctly", () => { const prompt: Prompt = [{ type: "file", path: "README.md", content: "@README.md", start: 0, end: 9 }] diff --git a/packages/app/src/context/file/path.test.ts b/packages/app/src/context/file/path.test.ts index 74bcbed0c689..6163bcfa6d02 100644 --- a/packages/app/src/context/file/path.test.ts +++ b/packages/app/src/context/file/path.test.ts @@ -77,6 +77,17 @@ describe("parseFileURL", () => { expect(parseFileURL("not a URL")).toBeUndefined() }) + test("normalizes single-slash URLs and preserves malformed escapes", () => { + expect(parseFileURL("file:/tmp/file%3F.txt")).toEqual({ + path: "/tmp/file?.txt", + url: "file:///tmp/file%3F.txt", + }) + expect(parseFileURL("file:///tmp/%zz.png")).toEqual({ + path: "/tmp/%zz.png", + url: "file:///tmp/%zz.png", + }) + }) + test("falls back when URL.canParse is unavailable", () => { const original = Object.getOwnPropertyDescriptor(URL, "canParse") Object.defineProperty(URL, "canParse", { configurable: true, value: undefined }) From 42922d0930bbf344e2bce3c4086f74fbcb4d92e9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:36:52 +0800 Subject: [PATCH 5/5] fix(app): keep model provider headers visible (#44115) Co-authored-by: OpenCode --- packages/app/src/components/dialog-select-model.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 9066f72434e5..587a36a66601 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -450,7 +450,7 @@ function ModelSelectorPopoverV2View(props: { {(group) => ( - + {group.items[0].provider.name}