Просмотр исходного кода

feat(desktop): open attachments in active project (#31192)

Luke Parker 3 месяцев назад
Родитель
Сommit
218147271c

+ 7 - 0
packages/app/src/components/directory-picker-policy.ts

@@ -0,0 +1,7 @@
+import { ServerConnection } from "@/context/server"
+import type { Platform } from "@/context/platform"
+
+export function directoryPickerKind(platform: Platform["platform"], server: ServerConnection.Any) {
+  if (platform === "desktop" && ServerConnection.local(server)) return "native" as const
+  return "server" as const
+}

+ 21 - 0
packages/app/src/components/directory-picker.test.ts

@@ -0,0 +1,21 @@
+import { describe, expect, test } from "bun:test"
+import { directoryPickerKind } from "./directory-picker-policy"
+
+const local = {
+  type: "sidecar",
+  variant: "base",
+  http: { url: "http://localhost:4096" },
+} as const
+const remote = {
+  type: "ssh",
+  host: "example.test",
+  http: { url: "http://localhost:4096" },
+} as const
+
+describe("directoryPickerKind", () => {
+  test("uses the native picker only for local desktop projects", () => {
+    expect(directoryPickerKind("desktop", local)).toBe("native")
+    expect(directoryPickerKind("desktop", remote)).toBe("server")
+    expect(directoryPickerKind("web", local)).toBe("server")
+  })
+})

+ 31 - 0
packages/app/src/components/directory-picker.tsx

@@ -0,0 +1,31 @@
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { ServerConnection } from "@/context/server"
+import { usePlatform } from "@/context/platform"
+import { DialogSelectDirectory } from "./dialog-select-directory"
+import { directoryPickerKind } from "./directory-picker-policy"
+
+type DirectoryPickerInput = {
+  server: ServerConnection.Any
+  title?: string
+  multiple?: boolean
+  onSelect: (result: string | string[] | null) => void
+}
+
+export function useDirectoryPicker() {
+  const platform = usePlatform()
+  const dialog = useDialog()
+
+  return (input: DirectoryPickerInput) => {
+    if (directoryPickerKind(platform.platform, input.server) === "native" && platform.platform === "desktop") {
+      void platform
+        .openDirectoryPickerDialog({ title: input.title, multiple: input.multiple })
+        .then(input.onSelect)
+      return
+    }
+
+    dialog.show(
+      () => <DialogSelectDirectory {...input} />,
+      () => input.onSelect(null),
+    )
+  }
+}

+ 22 - 13
packages/app/src/components/prompt-input.tsx

@@ -57,7 +57,7 @@ import { useSessionLayout } from "@/pages/session/session-layout"
 import { createSessionTabs } from "@/pages/session/helpers"
 import { createSessionTabs } from "@/pages/session/helpers"
 import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
 import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
 import { createPromptAttachments } from "./prompt-input/attachments"
 import { createPromptAttachments } from "./prompt-input/attachments"
-import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
+import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files"
 import {
 import {
   canNavigateHistoryAtCursor,
   canNavigateHistoryAtCursor,
   navigatePromptHistory,
   navigatePromptHistory,
@@ -73,6 +73,8 @@ import { PromptContextItems } from "./prompt-input/context-items"
 import { PromptImageAttachments } from "./prompt-input/image-attachments"
 import { PromptImageAttachments } from "./prompt-input/image-attachments"
 import { PromptDragOverlay } from "./prompt-input/drag-overlay"
 import { PromptDragOverlay } from "./prompt-input/drag-overlay"
 import { promptPlaceholder } from "./prompt-input/placeholder"
 import { promptPlaceholder } from "./prompt-input/placeholder"
+import { useDirectoryPicker } from "./directory-picker"
+import { showToast } from "@/utils/toast"
 import { ImagePreview } from "@opencode-ai/ui/image-preview"
 import { ImagePreview } from "@opencode-ai/ui/image-preview"
 import { useQueries } from "@tanstack/solid-query"
 import { useQueries } from "@tanstack/solid-query"
 import { useQueryOptions } from "@/context/server-sync"
 import { useQueryOptions } from "@/context/server-sync"
@@ -140,6 +142,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const permission = usePermission()
   const permission = usePermission()
   const language = useLanguage()
   const language = useLanguage()
   const platform = usePlatform()
   const platform = usePlatform()
+  const pickDirectory = useDirectoryPicker()
   const settings = useSettings()
   const settings = useSettings()
   const { params, tabs, view } = useSessionLayout()
   const { params, tabs, view } = useSessionLayout()
   let editorRef!: HTMLDivElement
   let editorRef!: HTMLDivElement
@@ -468,7 +471,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
 
   const pick = () => {
   const pick = () => {
     if (server.isLocal()) {
     if (server.isLocal()) {
-      fileInputRef?.click()
+      pickAttachmentFiles({
+        picker: platform.openAttachmentPickerDialog,
+        directory: () => sdk.directory,
+        fallback: () => fileInputRef?.click(),
+        onFile: addAttachment,
+        onError: (error) =>
+          showToast({
+            variant: "error",
+            title: language.t("common.requestFailed"),
+            description: error instanceof Error ? error.message : String(error),
+          }),
+      })
       return
       return
     }
     }
     void import("@/components/dialog-select-file").then((module) =>
     void import("@/components/dialog-select-file").then((module) =>
@@ -1094,7 +1108,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     return true
     return true
   }
   }
 
 
-  const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
+  const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
     editor: () => editorRef,
     editor: () => editorRef,
     isDialogActive: () => !!dialog.active,
     isDialogActive: () => !!dialog.active,
     setDraggingType: (type) => setStore("draggingType", type),
     setDraggingType: (type) => setStore("draggingType", type),
@@ -1385,7 +1399,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     server.projects.touch(worktree)
     server.projects.touch(worktree)
     navigate(`/${base64Encode(worktree)}/session`)
     navigate(`/${base64Encode(worktree)}/session`)
   }
   }
-  const addProject = async () => {
+  const addProject = () => {
     const conn = server.current
     const conn = server.current
     if (!conn) return
     if (!conn) return
     const select = (result: string | string[] | null) => {
     const select = (result: string | string[] | null) => {
@@ -1393,15 +1407,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       if (!directory) return
       if (!directory) return
       selectProject(directory)
       selectProject(directory)
     }
     }
-    if (platform.openDirectoryPickerDialog && server.isLocal()) {
-      select(await platform.openDirectoryPickerDialog({ title: language.t("command.project.open") }))
-      return
-    }
-    void import("@/components/dialog-select-directory").then((x) => {
-      dialog.show(
-        () => <x.DialogSelectDirectory onSelect={select} server={conn} />,
-        () => select(null),
-      )
+    pickDirectory({
+      server: conn,
+      title: language.t("command.project.open"),
+      onSelect: select,
     })
     })
   }
   }
 
 

+ 65 - 1
packages/app/src/components/prompt-input/attachments.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, test } from "bun:test"
 import { describe, expect, test } from "bun:test"
-import { attachmentMime } from "./files"
+import { attachmentMime, pickAttachmentFiles } from "./files"
 import { pasteMode } from "./paste"
 import { pasteMode } from "./paste"
 
 
 describe("attachmentMime", () => {
 describe("attachmentMime", () => {
@@ -24,6 +24,70 @@ describe("attachmentMime", () => {
   })
   })
 })
 })
 
 
+describe("pickAttachmentFiles", () => {
+  test("reads the current project directory for every native picker invocation", async () => {
+    const paths: string[] = []
+    const files: File[] = []
+    const file = new File(["hello"], "hello.txt", { type: "text/plain" })
+    let directory = "C:\\Projects\\LoremIpsum"
+    const picker = async (options?: { defaultPath?: string }, onFile?: (file: File) => Promise<unknown>) => {
+      paths.push(options?.defaultPath ?? "")
+      await onFile?.(file)
+    }
+
+    pickAttachmentFiles({
+      picker,
+      directory: () => directory,
+      fallback: () => undefined,
+      onFile: async (selected) => files.push(selected),
+      onError: () => undefined,
+    })
+    await Promise.resolve()
+    directory = "C:\\Projects\\DolorSit"
+    pickAttachmentFiles({
+      picker,
+      directory: () => directory,
+      fallback: () => undefined,
+      onFile: async (selected) => files.push(selected),
+      onError: () => undefined,
+    })
+    await Promise.resolve()
+    expect(files).toEqual([file, file])
+    expect(paths).toEqual(["C:\\Projects\\LoremIpsum", "C:\\Projects\\DolorSit"])
+  })
+
+  test("uses the browser file input when no native picker exists", async () => {
+    let fallback = 0
+    pickAttachmentFiles({
+      directory: () => "/projects/consectetur-adipiscing",
+      fallback: () => {
+        fallback += 1
+      },
+      onFile: async () => undefined,
+      onError: () => undefined,
+    })
+    expect(fallback).toBe(1)
+  })
+
+  test("reports native picker failures without rejecting", async () => {
+    const error = new Error("picker unavailable")
+    const errors: unknown[] = []
+    const handled = Promise.withResolvers<void>()
+    pickAttachmentFiles({
+      picker: async () => Promise.reject(error),
+      directory: () => "C:\\Projects\\LoremIpsum",
+      fallback: () => undefined,
+      onFile: async () => undefined,
+      onError: (cause) => {
+        errors.push(cause)
+        handled.resolve()
+      },
+    })
+    await handled.promise
+    expect(errors).toEqual([error])
+  })
+})
+
 describe("pasteMode", () => {
 describe("pasteMode", () => {
   test("uses native paste for short single-line text", () => {
   test("uses native paste for short single-line text", () => {
     expect(pasteMode("hello world")).toBe("native")
     expect(pasteMode("hello world")).toBe("native")

+ 29 - 0
packages/app/src/components/prompt-input/files.ts

@@ -2,6 +2,35 @@ import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-pick
 
 
 export { ACCEPTED_FILE_TYPES }
 export { ACCEPTED_FILE_TYPES }
 
 
+type AttachmentPicker = (options: {
+  defaultPath?: string
+  multiple?: boolean
+  accept?: string[]
+}, onFile: (file: File) => Promise<unknown>) => Promise<void>
+
+export function pickAttachmentFiles(input: {
+  picker?: AttachmentPicker
+  directory: () => string
+  fallback: () => void
+  onFile: (file: File) => Promise<unknown>
+  onError: (error: unknown) => void
+}) {
+  if (!input.picker) {
+    input.fallback()
+    return
+  }
+  void input
+    .picker(
+      {
+        defaultPath: input.directory(),
+        multiple: true,
+        accept: ACCEPTED_FILE_TYPES,
+      },
+      input.onFile,
+    )
+    .catch(input.onError)
+}
+
 const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
 const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
 const IMAGE_EXTS = new Map([
 const IMAGE_EXTS = new Map([
   ["gif", "image/gif"],
   ["gif", "image/gif"],

+ 21 - 14
packages/app/src/context/platform.tsx

@@ -8,7 +8,13 @@ import type { UpdaterPlatform } from "../updater"
 
 
 type PickerPaths = string | string[] | null
 type PickerPaths = string | string[] | null
 type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
 type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
-type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
+type OpenAttachmentPickerOptions = {
+  title?: string
+  multiple?: boolean
+  accept?: string[]
+  extensions?: string[]
+  defaultPath?: string
+}
 type SaveFilePickerOptions = { title?: string; defaultPath?: string }
 type SaveFilePickerOptions = { title?: string; defaultPath?: string }
 type PlatformName = "web" | "desktop"
 type PlatformName = "web" | "desktop"
 type DesktopOS = "macos" | "windows" | "linux"
 type DesktopOS = "macos" | "windows" | "linux"
@@ -21,13 +27,7 @@ export type FatalRendererErrorLog = {
   os?: DesktopOS
   os?: DesktopOS
 }
 }
 
 
-export type Platform = {
-  /** Platform discriminator */
-  platform: PlatformName
-
-  /** Desktop OS (Tauri only) */
-  os?: DesktopOS
-
+type PlatformBase = {
   /** App version */
   /** App version */
   version?: string
   version?: string
 
 
@@ -49,13 +49,10 @@ export type Platform = {
   /** Send a system notification (optional deep link) */
   /** Send a system notification (optional deep link) */
   notify(title: string, description?: string, href?: string): Promise<void>
   notify(title: string, description?: string, href?: string): Promise<void>
 
 
-  /** Open directory picker dialog (native on Tauri, server-backed on web) */
-  openDirectoryPickerDialog?(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths>
-
-  /** Open native file picker dialog (Tauri only) */
-  openFilePickerDialog?(opts?: OpenFilePickerOptions): Promise<PickerPaths>
+  /** Open a native attachment picker and read selected files sequentially (desktop only) */
+  openAttachmentPickerDialog?(opts: OpenAttachmentPickerOptions, onFile: (file: File) => Promise<unknown>): Promise<void>
 
 
-  /** Save file picker dialog (Tauri only) */
+  /** Open a native save file picker dialog (desktop only) */
   saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
   saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
 
 
   /** Storage mechanism, defaults to localStorage */
   /** Storage mechanism, defaults to localStorage */
@@ -110,6 +107,16 @@ export type Platform = {
   recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
   recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
 }
 }
 
 
+export type Platform = PlatformBase &
+  (
+    | { platform: "web"; os?: never }
+    | {
+        platform: "desktop"
+        os?: DesktopOS
+        openDirectoryPickerDialog(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths>
+      }
+  )
+
 export type DisplayBackend = "auto" | "wayland"
 export type DisplayBackend = "auto" | "wayland"
 
 
 export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
 export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({

+ 17 - 28
packages/app/src/pages/home.tsx

@@ -18,7 +18,7 @@ import { Icon } from "@opencode-ai/ui/icon"
 import { usePlatform } from "@/context/platform"
 import { usePlatform } from "@/context/platform"
 import { DateTime } from "luxon"
 import { DateTime } from "luxon"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { DialogSelectDirectory } from "@/components/dialog-select-directory"
+import { useDirectoryPicker } from "@/components/directory-picker"
 import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
 import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
 import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
 import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
 import { ServerConnection, useServer } from "@/context/server"
 import { ServerConnection, useServer } from "@/context/server"
@@ -125,6 +125,7 @@ function HomeDesign() {
   const sync = useServerSync()
   const sync = useServerSync()
   const layout = useLayout()
   const layout = useLayout()
   const platform = usePlatform()
   const platform = usePlatform()
+  const pickDirectory = useDirectoryPicker()
   const dialog = useDialog()
   const dialog = useDialog()
   const navigate = useNavigate()
   const navigate = useNavigate()
   const server = useServer()
   const server = useServer()
@@ -314,26 +315,19 @@ function HomeDesign() {
     navigateOnServer(conn, `/${base64Encode(session.directory)}/session/${session.id}`)
     navigateOnServer(conn, `/${base64Encode(session.directory)}/session/${session.id}`)
   }
   }
 
 
-  async function chooseProject(conn: ServerConnection.Any) {
+  function chooseProject(conn: ServerConnection.Any) {
     function resolve(result: string | string[] | null) {
     function resolve(result: string | string[] | null) {
       addProjects(conn, homeProjectDirectories(result))
       addProjects(conn, homeProjectDirectories(result))
     }
     }
 
 
     const server = global.createServerCtx(conn)
     const server = global.createServerCtx(conn)
 
 
-    if (platform.openDirectoryPickerDialog && server.isLocal) {
-      const result = await platform.openDirectoryPickerDialog?.({
-        title: language.t("command.project.open"),
-        multiple: true,
-      })
-      resolve(result)
-      return
-    }
-
-    dialog.show(
-      () => <DialogSelectDirectory multiple={true} onSelect={resolve} server={conn} />,
-      () => resolve(null),
-    )
+    pickDirectory({
+      server: conn,
+      title: language.t("command.project.open"),
+      multiple: true,
+      onSelect: resolve,
+    })
   }
   }
 
 
   function openSettings() {
   function openSettings() {
@@ -1101,6 +1095,7 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
 function LegacyHome() {
 function LegacyHome() {
   const sync = useServerSync()
   const sync = useServerSync()
   const platform = usePlatform()
   const platform = usePlatform()
+  const pickDirectory = useDirectoryPicker()
   const dialog = useDialog()
   const dialog = useDialog()
   const navigate = useNavigate()
   const navigate = useNavigate()
   const global = useGlobal()
   const global = useGlobal()
@@ -1128,7 +1123,7 @@ function LegacyHome() {
     navigate(`/${base64Encode(directory)}`)
     navigate(`/${base64Encode(directory)}`)
   }
   }
 
 
-  async function chooseProject() {
+  function chooseProject() {
     const s = server.current
     const s = server.current
     if (!s) return
     if (!s) return
 
 
@@ -1142,18 +1137,12 @@ function LegacyHome() {
       }
       }
     }
     }
 
 
-    if (platform.openDirectoryPickerDialog && server.isLocal()) {
-      const result = await platform.openDirectoryPickerDialog?.({
-        title: language.t("command.project.open"),
-        multiple: true,
-      })
-      resolve(result)
-    } else {
-      dialog.show(
-        () => <DialogSelectDirectory multiple={true} onSelect={resolve} server={s} />,
-        () => resolve(null),
-      )
-    }
+    pickDirectory({
+      server: s,
+      title: language.t("command.project.open"),
+      multiple: true,
+      onSelect: resolve,
+    })
   }
   }
 
 
   return (
   return (

+ 9 - 17
packages/app/src/pages/layout.tsx

@@ -64,6 +64,7 @@ import { useCommand, type CommandOption } from "@/context/command"
 import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
 import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
 import { DebugBar } from "@/components/debug-bar"
 import { DebugBar } from "@/components/debug-bar"
 import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
 import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
+import { useDirectoryPicker } from "@/components/directory-picker"
 import { ServerConnection, useServer } from "@/context/server"
 import { ServerConnection, useServer } from "@/context/server"
 import { useLanguage, type Locale } from "@/context/language"
 import { useLanguage, type Locale } from "@/context/language"
 import { pathKey } from "@/utils/path-key"
 import { pathKey } from "@/utils/path-key"
@@ -117,6 +118,7 @@ export default function Layout(props: ParentProps) {
   const layout = useLayout()
   const layout = useLayout()
   const layoutReady = createMemo(() => layout.ready())
   const layoutReady = createMemo(() => layout.ready())
   const platform = usePlatform()
   const platform = usePlatform()
+  const pickDirectory = useDirectoryPicker()
   const settings = useSettings()
   const settings = useSettings()
   const server = useServer()
   const server = useServer()
   const notification = useNotification()
   const notification = useNotification()
@@ -1457,7 +1459,7 @@ export default function Layout(props: ParentProps) {
     })
     })
   }
   }
 
 
-  async function chooseProject() {
+  function chooseProject() {
     const conn = server.current
     const conn = server.current
     if (!conn) return
     if (!conn) return
     function resolve(result: string | string[] | null) {
     function resolve(result: string | string[] | null) {
@@ -1471,22 +1473,12 @@ export default function Layout(props: ParentProps) {
       }
       }
     }
     }
 
 
-    if (platform.openDirectoryPickerDialog && server.isLocal()) {
-      const result = await platform.openDirectoryPickerDialog?.({
-        title: language.t("command.project.open"),
-        multiple: true,
-      })
-      resolve(result)
-    } else {
-      const run = ++dialogRun
-      void import("@/components/dialog-select-directory").then((x) => {
-        if (dialogDead || dialogRun !== run) return
-        dialog.show(
-          () => <x.DialogSelectDirectory multiple={true} onSelect={resolve} server={conn} />,
-          () => resolve(null),
-        )
-      })
-    }
+    pickDirectory({
+      server: conn,
+      title: language.t("command.project.open"),
+      multiple: true,
+      onSelect: resolve,
+    })
   }
   }
 
 
   const deleteWorkspace = async (root: string, directory: string, leaveDeletedWorkspace = false) => {
   const deleteWorkspace = async (root: string, directory: string, leaveDeletedWorkspace = false) => {

+ 85 - 0
packages/desktop/src/main/attachment-picker.test.ts

@@ -0,0 +1,85 @@
+import { describe, expect, test } from "bun:test"
+import { mkdtemp, rm, truncate, writeFile } from "node:fs/promises"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import {
+  assertAttachmentBudget,
+  createPickedFileAuthorizations,
+  MAX_ATTACHMENT_BYTES,
+  readAttachment,
+} from "./attachment-picker"
+
+describe("assertAttachmentBudget", () => {
+  test("accepts selections within the media ingest limit", () => {
+    expect(() => assertAttachmentBudget([{ size: MAX_ATTACHMENT_BYTES / 2 }, { size: MAX_ATTACHMENT_BYTES / 2 }])).not.toThrow()
+  })
+
+  test("rejects the selection before files are read when its total exceeds the limit", () => {
+    expect(() => assertAttachmentBudget([{ size: MAX_ATTACHMENT_BYTES }, { size: 1 }])).toThrow("20 MB limit")
+  })
+
+  test("reads an approved file through a bounded buffer", async () => {
+    const directory = await mkdtemp(join(tmpdir(), "opencode-attachment-"))
+    const file = join(directory, "example.txt")
+    try {
+      await writeFile(file, "lorem ipsum")
+      expect(new TextDecoder().decode(await readAttachment(file))).toBe("lorem ipsum")
+    } finally {
+      await rm(directory, { recursive: true, force: true })
+    }
+  })
+
+  test("rejects an oversized file before allocating its contents", async () => {
+    const directory = await mkdtemp(join(tmpdir(), "opencode-attachment-"))
+    const file = join(directory, "oversized.txt")
+    try {
+      await writeFile(file, "")
+      await truncate(file, MAX_ATTACHMENT_BYTES + 1)
+      await expect(readAttachment(file)).rejects.toThrow("20 MB limit")
+    } finally {
+      await rm(directory, { recursive: true, force: true })
+    }
+  })
+})
+
+describe("picked file authorizations", () => {
+  const read = async (path: string) => new TextEncoder().encode(path).buffer
+
+  test("keeps concurrent picker selections isolated", async () => {
+    const authorizations = createPickedFileAuthorizations(read)
+    const first = authorizations.add(1, ["a.txt", "b.txt"])
+    const second = authorizations.add(1, ["c.txt"])
+
+    expect(new TextDecoder().decode(await authorizations.read(1, first, "a.txt"))).toBe("a.txt")
+    expect(new TextDecoder().decode(await authorizations.read(1, second, "c.txt"))).toBe("c.txt")
+    expect(new TextDecoder().decode(await authorizations.read(1, first, "b.txt"))).toBe("b.txt")
+  })
+
+  test("releases unread files for one picker without affecting another", async () => {
+    const authorizations = createPickedFileAuthorizations(read)
+    const first = authorizations.add(1, ["a.txt"])
+    const second = authorizations.add(1, ["b.txt"])
+    authorizations.release(1, first)
+
+    await expect(authorizations.read(1, first, "a.txt")).rejects.toThrow("not selected")
+    expect(new TextDecoder().decode(await authorizations.read(1, second, "b.txt"))).toBe("b.txt")
+  })
+
+  test("keeps picker tokens scoped to their renderer", async () => {
+    const authorizations = createPickedFileAuthorizations(read)
+    const token = authorizations.add(1, ["a.txt"])
+
+    await expect(authorizations.read(2, token, "a.txt")).rejects.toThrow("not selected")
+  })
+
+  test("charges actual reads against the selection budget", async () => {
+    const authorizations = createPickedFileAuthorizations(async (_path, maxBytes) => {
+      if (6 > maxBytes) throw new Error("budget exceeded")
+      return new ArrayBuffer(6)
+    }, 10)
+    const token = authorizations.add(1, ["a.txt", "b.txt"])
+
+    await authorizations.read(1, token, "a.txt")
+    await expect(authorizations.read(1, token, "b.txt")).rejects.toThrow("budget exceeded")
+  })
+})

+ 54 - 0
packages/desktop/src/main/attachment-picker.ts

@@ -0,0 +1,54 @@
+import { randomUUID } from "node:crypto"
+import { open } from "node:fs/promises"
+
+export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
+
+export function createPickedFileAuthorizations(
+  read: (path: string, maxBytes: number) => Promise<ArrayBuffer> = readAttachment,
+  budget = MAX_ATTACHMENT_BYTES,
+) {
+  const selections = new Map<string, { sender: number; paths: Set<string>; remaining: number }>()
+
+  return {
+    add(sender: number, paths: string[]) {
+      const token = randomUUID()
+      selections.set(token, { sender, paths: new Set(paths), remaining: budget })
+      return token
+    },
+    async read(sender: number, token: string, path: string) {
+      const selection = selections.get(token)
+      if (selection?.sender !== sender || !selection.paths.delete(path)) throw new Error("File was not selected by the picker")
+      const bytes = await read(path, selection.remaining)
+      selection.remaining -= bytes.byteLength
+      if (selection.paths.size === 0) selections.delete(token)
+      return bytes
+    },
+    release(sender: number, token: string) {
+      if (selections.get(token)?.sender === sender) selections.delete(token)
+    },
+  }
+}
+
+export function assertAttachmentBudget(files: { size: number }[]) {
+  const total = files.reduce((sum, file) => sum + file.size, 0)
+  if (total <= MAX_ATTACHMENT_BYTES) return
+  throw new Error(`Selected attachments exceed the ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB limit`)
+}
+
+export async function readAttachment(filePath: string, maxBytes = MAX_ATTACHMENT_BYTES) {
+  const file = await open(filePath, "r")
+  try {
+    const info = await file.stat()
+    if (info.size > maxBytes) throw new Error(`Selected attachments exceed the ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB limit`)
+    const bytes = Buffer.allocUnsafe(info.size)
+    let offset = 0
+    while (offset < info.size) {
+      const result = await file.read(bytes, offset, info.size - offset, offset)
+      if (result.bytesRead === 0) break
+      offset += result.bytesRead
+    }
+    return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + offset) as ArrayBuffer
+  } finally {
+    await file.close()
+  }
+}

+ 21 - 3
packages/desktop/src/main/ipc.ts

@@ -1,10 +1,13 @@
 import { execFile } from "node:child_process"
 import { execFile } from "node:child_process"
+import { stat } from "node:fs/promises"
+import { basename } from "node:path"
 import { app, BrowserWindow, Notification, clipboard, dialog, ipcMain, shell } from "electron"
 import { app, BrowserWindow, Notification, clipboard, dialog, ipcMain, shell } from "electron"
 import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
 import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
 import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
 import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
 
 
 import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
 import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
 import { runDesktopMenuAction } from "./desktop-menu-actions"
 import { runDesktopMenuAction } from "./desktop-menu-actions"
+import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
 import { getStore } from "./store"
 import { getStore } from "./store"
 import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
 import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
 import type { UpdaterController } from "./updater-controller"
 import type { UpdaterController } from "./updater-controller"
@@ -15,6 +18,8 @@ const pickerFilters = (ext?: string[]) => {
   return [{ name: "Files", extensions: ext }]
   return [{ name: "Files", extensions: ext }]
 }
 }
 
 
+const pickedFiles = createPickedFileAuthorizations()
+
 type Deps = {
 type Deps = {
   killSidecar: () => Promise<void> | void
   killSidecar: () => Promise<void> | void
   relaunch: () => void
   relaunch: () => void
@@ -115,8 +120,8 @@ export function registerIpcHandlers(deps: Deps) {
   ipcMain.handle(
   ipcMain.handle(
     "open-file-picker",
     "open-file-picker",
     async (
     async (
-      _event: IpcMainInvokeEvent,
-      opts?: { multiple?: boolean; title?: string; defaultPath?: string; accept?: string[]; extensions?: string[] },
+      event: IpcMainInvokeEvent,
+      opts?: { multiple?: boolean; title?: string; defaultPath?: string; extensions?: string[] },
     ) => {
     ) => {
       const result = await dialog.showOpenDialog({
       const result = await dialog.showOpenDialog({
         properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
         properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
@@ -125,10 +130,23 @@ export function registerIpcHandlers(deps: Deps) {
         filters: pickerFilters(opts?.extensions),
         filters: pickerFilters(opts?.extensions),
       })
       })
       if (result.canceled) return null
       if (result.canceled) return null
-      return opts?.multiple ? result.filePaths : result.filePaths[0]
+      const files = await Promise.all(
+        result.filePaths.map(async (filePath) => ({ path: filePath, name: basename(filePath), size: (await stat(filePath)).size })),
+      )
+      assertAttachmentBudget(files)
+      const token = pickedFiles.add(event.sender.id, result.filePaths)
+      return { token, files }
     },
     },
   )
   )
 
 
+  ipcMain.handle("read-picked-file", async (event: IpcMainInvokeEvent, token: string, filePath: string) => {
+    return pickedFiles.read(event.sender.id, token, filePath)
+  })
+
+  ipcMain.handle("release-picked-files", (event: IpcMainInvokeEvent, token: string) => {
+    pickedFiles.release(event.sender.id, token)
+  })
+
   ipcMain.handle(
   ipcMain.handle(
     "save-file-picker",
     "save-file-picker",
     async (_event: IpcMainInvokeEvent, opts?: { title?: string; defaultPath?: string }) => {
     async (_event: IpcMainInvokeEvent, opts?: { title?: string; defaultPath?: string }) => {

+ 2 - 0
packages/desktop/src/preload/index.ts

@@ -86,6 +86,8 @@ const api: ElectronAPI = {
 
 
   openDirectoryPicker: (opts) => ipcRenderer.invoke("open-directory-picker", opts),
   openDirectoryPicker: (opts) => ipcRenderer.invoke("open-directory-picker", opts),
   openFilePicker: (opts) => ipcRenderer.invoke("open-file-picker", opts),
   openFilePicker: (opts) => ipcRenderer.invoke("open-file-picker", opts),
+  readPickedFile: (token, path) => ipcRenderer.invoke("read-picked-file", token, path),
+  releasePickedFiles: (token) => ipcRenderer.invoke("release-picked-files", token),
   saveFilePicker: (opts) => ipcRenderer.invoke("save-file-picker", opts),
   saveFilePicker: (opts) => ipcRenderer.invoke("save-file-picker", opts),
   openLink: (url) => ipcRenderer.send("open-link", url),
   openLink: (url) => ipcRenderer.send("open-link", url),
   openPath: (path, app) => ipcRenderer.invoke("open-path", path, app),
   openPath: (path, app) => ipcRenderer.invoke("open-path", path, app),

+ 3 - 2
packages/desktop/src/preload/types.ts

@@ -74,9 +74,10 @@ export type ElectronAPI = {
     multiple?: boolean
     multiple?: boolean
     title?: string
     title?: string
     defaultPath?: string
     defaultPath?: string
-    accept?: string[]
     extensions?: string[]
     extensions?: string[]
-  }) => Promise<string | string[] | null>
+  }) => Promise<{ token: string; files: { path: string; name: string; size: number }[] } | null>
+  readPickedFile: (token: string, path: string) => Promise<ArrayBuffer>
+  releasePickedFiles: (token: string) => Promise<void>
   saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
   saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
   openLink: (url: string) => void
   openLink: (url: string) => void
   openPath: (path: string, app?: string) => Promise<void>
   openPath: (path: string, app?: string) => Promise<void>

+ 11 - 4
packages/desktop/src/renderer/index.tsx

@@ -2,7 +2,6 @@
 
 
 import {
 import {
   ACCEPTED_FILE_EXTENSIONS,
   ACCEPTED_FILE_EXTENSIONS,
-  ACCEPTED_FILE_TYPES,
   AppBaseProviders,
   AppBaseProviders,
   AppInterface,
   AppInterface,
   handleNotificationClick,
   handleNotificationClick,
@@ -144,13 +143,21 @@ const createPlatform = (): Platform => {
       })
       })
     },
     },
 
 
-    async openFilePickerDialog(opts) {
-      return window.api.openFilePicker({
+    async openAttachmentPickerDialog(opts, onFile) {
+      const result = await window.api.openFilePicker({
         multiple: opts?.multiple ?? false,
         multiple: opts?.multiple ?? false,
         title: opts?.title ?? t("desktop.dialog.chooseFile"),
         title: opts?.title ?? t("desktop.dialog.chooseFile"),
-        accept: opts?.accept ?? ACCEPTED_FILE_TYPES,
+        defaultPath: opts?.defaultPath,
         extensions: opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS,
         extensions: opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS,
       })
       })
+      if (!result) return
+      try {
+        for (const file of result.files) {
+          await onFile(new File([await window.api.readPickedFile(result.token, file.path)], file.name))
+        }
+      } finally {
+        await window.api.releasePickedFiles(result.token)
+      }
     },
     },
 
 
     async saveFilePickerDialog(opts) {
     async saveFilePickerDialog(opts) {