Răsfoiți Sursa

Merge branch 'dev' of github.com:anomalyco/opencode into dev

Frank 1 săptămână în urmă
părinte
comite
9465a6c97d
33 a modificat fișierele cu 504 adăugiri și 84 ștergeri
  1. 1 2
      .github/workflows/publish.yml
  2. 1 0
      bun.lock
  3. 2 1
      packages/app/src/components/prompt-input-v2.tsx
  4. 1 1
      packages/app/src/components/prompt-input.tsx
  5. 6 21
      packages/app/src/components/prompt-input/attachments.ts
  6. 1 1
      packages/app/src/components/prompt-input/build-request-parts.ts
  7. 22 6
      packages/app/src/components/prompt-input/history-store.ts
  8. 1 1
      packages/app/src/components/prompt-input/history.test.ts
  9. 1 1
      packages/app/src/components/prompt-input/image-attachments.tsx
  10. 20 9
      packages/app/src/components/prompt-input/submit.ts
  11. 4 0
      packages/app/src/context/platform.tsx
  12. 5 4
      packages/app/src/context/prompt-state.ts
  13. 4 1
      packages/app/src/context/tabs.tsx
  14. 2 0
      packages/app/src/entry.tsx
  15. 1 0
      packages/app/src/index.ts
  16. 171 0
      packages/app/src/utils/draft-store.ts
  17. 47 5
      packages/app/src/utils/persist.ts
  18. 12 2
      packages/app/src/utils/prompt.test.ts
  19. 2 1
      packages/app/src/utils/prompt.ts
  20. 49 1
      packages/app/test-browser/prompt-persistence.test.ts
  21. 2 1
      packages/desktop/package.json
  22. 16 0
      packages/desktop/src/main/draft-store.test.ts
  23. 82 0
      packages/desktop/src/main/draft-store.ts
  24. 2 2
      packages/desktop/src/main/index.ts
  25. 14 1
      packages/desktop/src/main/ipc.ts
  26. 5 0
      packages/desktop/src/preload/index.ts
  27. 5 0
      packages/desktop/src/preload/types.ts
  28. 8 0
      packages/desktop/src/renderer/index.tsx
  29. 11 17
      packages/session-ui/src/v2/components/prompt-input/attachments.ts
  30. 1 1
      packages/session-ui/src/v2/components/prompt-input/index.tsx
  31. 2 2
      packages/session-ui/src/v2/components/prompt-input/prompt-input.stories.tsx
  32. 2 2
      packages/session-ui/src/v2/components/prompt-input/store.test.ts
  33. 1 1
      packages/session-ui/src/v2/components/prompt-input/types.ts

+ 1 - 2
.github/workflows/publish.yml

@@ -339,8 +339,7 @@ jobs:
         env:
           OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
           GH_TOKEN: ${{ steps.committer.outputs.token }}
-          CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
-          CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
+          CSC_KEYCHAIN: build.keychain
           APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
           APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
           APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}

+ 1 - 0
bun.lock

@@ -384,6 +384,7 @@
       "version": "1.18.11",
       "dependencies": {
         "@zip.js/zip.js": "2.7.62",
+        "drizzle-orm": "catalog:",
         "effect": "catalog:",
         "electron-context-menu": "4.1.2",
         "electron-log": "^5",

+ 2 - 1
packages/app/src/components/prompt-input-v2.tsx

@@ -344,7 +344,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
       if (item?.commentID) comments.remove(item.path, item.commentID)
     },
     openAttachment: (attachment) =>
-      dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />),
+      dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />),
     openContext(key) {
       const item = controller.contextItem(key)
       if (item) openComment(item, props, sync, layout, files, comments)
@@ -377,6 +377,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
         }),
       readClipboardImage: platform.readClipboardImage,
       getPathForFile: platform.getPathForFile,
+      store: platform.draftStore?.putBlob,
     },
     view: {
       placeholder: designPlaceholder,

+ 1 - 1
packages/app/src/components/prompt-input.tsx

@@ -1489,7 +1489,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         <PromptImageAttachments
           attachments={imageAttachments()}
           onOpen={(attachment) =>
-            dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
+            dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />)
           }
           onRemove={removeAttachment}
           removeLabel={language.t("prompt.attachment.remove")}

+ 6 - 21
packages/app/src/components/prompt-input/attachments.ts

@@ -3,28 +3,13 @@ import { makeEventListener } from "@solid-primitives/event-listener"
 import { showToast } from "@/utils/toast"
 import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt"
 import { useLanguage } from "@/context/language"
+import { usePlatform } from "@/context/platform"
 import { uuid } from "@/utils/uuid"
 import { getCursorPosition } from "./editor-dom"
+import { createBlobReference, type DraftStore } from "@/utils/draft-store"
 import { attachmentMime } from "./files"
 import { normalizePaste, pasteMode } from "./paste"
 
-function dataUrl(file: File, mime: string) {
-  return new Promise<string>((resolve) => {
-    const reader = new FileReader()
-    reader.addEventListener("error", () => resolve(""))
-    reader.addEventListener("load", () => {
-      const value = typeof reader.result === "string" ? reader.result : ""
-      const idx = value.indexOf(",")
-      if (idx === -1) {
-        resolve(value)
-        return
-      }
-      resolve(`data:${mime};base64,${value.slice(idx + 1)}`)
-    })
-    reader.readAsDataURL(file)
-  })
-}
-
 type PromptTarget = Pick<ReturnType<ReturnType<typeof usePrompt>["capture"]>, "current" | "cursor" | "set">
 type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined }
 
@@ -36,6 +21,7 @@ type PromptAttachmentsCoreInput = {
   warn?: () => void
   readClipboardImage?: () => Promise<File | null>
   getPathForFile?: (file: File) => string
+  draftStore?: DraftStore
 }
 
 export type PromptAttachmentsInput = {
@@ -65,16 +51,13 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
       return false
     }
 
-    const url = await dataUrl(file, mime)
-    if (!url) return false
-
     const attachment: ImageAttachmentPart = {
       type: "image",
       id: uuid(),
       filename: file.name,
       sourcePath: input.getPathForFile?.(file) || undefined,
       mime,
-      dataUrl: url,
+      blob: input.draftStore ? await input.draftStore.putBlob(file) : await createBlobReference(file),
     }
     target.prompt.set([...target.prompt.current(), attachment], target.cursor)
     return true
@@ -166,8 +149,10 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
 
 export function createPromptAttachments(input: PromptAttachmentsInput) {
   const language = useLanguage()
+  const platform = usePlatform()
   const attachments = createPromptAttachmentsCore({
     ...input,
+    draftStore: platform.draftStore,
     capture: input.prompt.capture,
     warn: () => {
       showToast({

+ 1 - 1
packages/app/src/components/prompt-input/build-request-parts.ts

@@ -22,7 +22,7 @@ type ContextFile = {
 type BuildRequestPartsInput = {
   prompt: Prompt
   context: ContextFile[]
-  images: ImageAttachmentPart[]
+  images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
   text: string
   messageID: string
   sessionID: string

+ 22 - 6
packages/app/src/components/prompt-input/history-store.ts

@@ -1,7 +1,13 @@
 import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
 import type { Prompt } from "@/context/prompt"
 import { Persist, persisted } from "@/utils/persist"
-import { prependHistoryEntry, type PromptHistoryComment, type PromptHistoryStoredEntry } from "./history"
+import {
+  clonePromptHistoryComments,
+  clonePromptParts,
+  prependHistoryEntry,
+  type PromptHistoryComment,
+  type PromptHistoryStoredEntry,
+} from "./history"
 
 export type PromptInputHistory = {
   entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
@@ -35,13 +41,23 @@ export function createPromptInputHistory(): PromptInputHistory {
 }
 
 export function createPersistedPromptInputHistory() {
-  const [normal, setNormal] = persisted(
-    Persist.global("prompt-history", ["prompt-history.v1"]),
+  const [normal, setNormal, normalInit] = persisted(
+    Persist.prompt(Persist.global("prompt-history", ["prompt-history.v1"])),
     createStore<PromptHistoryState>({ entries: [] }),
   )
-  const [shell, setShell] = persisted(
-    Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
+  const [shell, setShell, shellInit] = persisted(
+    Persist.prompt(Persist.global("prompt-history-shell", ["prompt-history-shell.v1"])),
     createStore<PromptHistoryState>({ entries: [] }),
   )
-  return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
+  const history = createPromptInputHistoryStore(normal, setNormal, shell, setShell)
+  return {
+    ...history,
+    add(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) {
+      const ready = mode === "shell" ? shellInit : normalInit
+      if (!(ready instanceof Promise)) return history.add(prompt, mode, comments)
+      const saved = clonePromptParts(prompt)
+      const metadata = clonePromptHistoryComments(comments)
+      void ready.then(() => history.add(saved, mode, metadata))
+    },
+  }
 }

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

@@ -112,7 +112,7 @@ describe("prompt-input history", () => {
         end: 12,
         selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
       },
-      { type: "image", id: "1", filename: "img.png", mime: "image/png", dataUrl: "data:image/png;base64,abc" },
+      { type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } },
     ]
     const copy = clonePromptParts(original)
     expect(copy).not.toBe(original)

+ 1 - 1
packages/app/src/components/prompt-input/image-attachments.tsx

@@ -100,7 +100,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
                   }
                 >
                   <img
-                    src={attachment.dataUrl}
+                    src={attachment.blob.url}
                     alt={attachment.filename}
                     class={props.newLayoutDesigns ? imageClassV2 : imageClass}
                     onClick={() => props.onOpen(attachment)}

+ 20 - 9
packages/app/src/components/prompt-input/submit.ts

@@ -22,6 +22,7 @@ import { ScopedKey } from "@/utils/server-scope"
 import { createPromptSubmissionState } from "./submission-state"
 import { normalizeSessionInfo } from "@/utils/session"
 import { Event } from "@opencode-ai/schema/event"
+import { blobDataUrl } from "@/utils/draft-store"
 
 type PendingPrompt = {
   abort: AbortController
@@ -95,10 +96,12 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
           providerID: input.draft.model.providerID,
           variant: input.draft.variant,
         },
-        files: images.map((attachment) => ({
-          uri: attachment.dataUrl,
-          name: attachment.filename,
-        })),
+        files: await Promise.all(
+          images.map(async (attachment) => ({
+            uri: await blobDataUrl(attachment.blob, attachment.mime),
+            name: attachment.filename,
+          })),
+        ),
       })
       return true
     } catch (err) {
@@ -108,10 +111,16 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
   }
 
   const messageID = input.messageID ?? Identifier.ascending("message")
+  const encodedImages = await Promise.all(
+    images.map(async (attachment) => ({
+      ...attachment,
+      dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
+    })),
+  )
   const { requestParts, optimisticParts } = buildRequestParts({
     prompt: input.draft.prompt,
     context: input.draft.context,
-    images,
+    images: encodedImages,
     text,
     sessionID: input.draft.sessionID,
     messageID,
@@ -516,10 +525,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
             arguments: args.join(" "),
             agent,
             model: { id: model.modelID, providerID: model.providerID, variant },
-            files: images.map((attachment) => ({
-              uri: attachment.dataUrl,
-              name: attachment.filename,
-            })),
+            files: await Promise.all(
+              images.map(async (attachment) => ({
+                uri: await blobDataUrl(attachment.blob, attachment.mime),
+                name: attachment.filename,
+              })),
+            ),
           })
           .catch((err) => {
             serverSync().session.set("session_status", session.id, { type: "idle" })

+ 4 - 0
packages/app/src/context/platform.tsx

@@ -5,6 +5,7 @@ import type { DesktopMenuAction } from "../desktop-menu"
 import { ServerConnection } from "./server"
 import type { WslServersPlatform } from "../wsl/types"
 import type { UpdaterPlatform } from "../updater"
+import type { DraftStore } from "@/utils/draft-store"
 
 type PickerPaths = string | string[] | null
 type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
@@ -64,6 +65,9 @@ type PlatformBase = {
   /** Storage mechanism, defaults to localStorage */
   storage?: (name?: string) => SyncStorage | AsyncStorage
 
+  /** Prompt drafts, history, and their blobs. */
+  draftStore?: DraftStore
+
   /** Stable platform window identity for window-scoped persistence */
   windowID?: string
 

+ 5 - 4
packages/app/src/context/prompt-state.ts

@@ -5,6 +5,7 @@ import { createStore, type SetStoreFunction } from "solid-js/store"
 import type { FileSelection } from "@/context/file"
 import { Persist, persisted } from "@/utils/persist"
 import type { ServerScope } from "@/utils/server-scope"
+import type { BlobReference } from "@/utils/draft-store"
 
 interface PartBase {
   content: string
@@ -37,7 +38,7 @@ export interface ImageAttachmentPart {
   filename: string
   sourcePath?: string
   mime: string
-  dataUrl: string
+  blob: BlobReference
 }
 
 export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
@@ -168,9 +169,9 @@ function createPromptActions(setStore: SetStoreFunction<PromptStore>) {
 }
 
 function promptTarget(serverScope: ServerScope, scope: PromptScope) {
-  if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
+  if ("draftID" in scope) return Persist.prompt(Persist.draft(scope.draftID, "prompt"))
   const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
-  return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
+  return Persist.prompt(Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy]))
 }
 
 function promptStore(initial?: InitialPrompt): PromptStore {
@@ -245,7 +246,7 @@ export function createPromptSession(serverScope: ServerScope, scope: PromptScope
 }
 
 export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) {
-  return createPersistedPrompt(Persist.draft(draftID, "prompt"), initial)
+  return createPersistedPrompt(Persist.prompt(Persist.draft(draftID, "prompt")), initial)
 }
 
 export type PromptSession = ReturnType<typeof createPromptSession>

+ 4 - 1
packages/app/src/context/tabs.tsx

@@ -101,7 +101,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
     }
 
     const removeDraftPersisted = (draftID: string) => {
-      for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
+      for (const key of draftPersistedKeys()) {
+        const target = Persist.draft(draftID, key)
+        removePersisted(key === "prompt" ? Persist.prompt(target) : target, platform)
+      }
     }
 
     const removeInfo = (key: string) => {

+ 2 - 0
packages/app/src/entry.tsx

@@ -4,6 +4,7 @@ import * as Sentry from "@sentry/solid"
 import { render } from "solid-js/web"
 import { AppBaseProviders, AppInterface } from "@/app"
 import { type Platform, PlatformProvider } from "@/context/platform"
+import { createBrowserDraftStore } from "@/utils/draft-store"
 import { dict as en } from "@/i18n/en"
 import { dict as zh } from "@/i18n/zh"
 import { authFromToken } from "@/utils/server"
@@ -116,6 +117,7 @@ const clearAuthToken = () => {
 
 const platform: Platform = {
   platform: "web",
+  draftStore: createBrowserDraftStore(),
   version: pkg.version,
   openExternal,
   restart,

+ 1 - 0
packages/app/src/index.ts

@@ -27,3 +27,4 @@ export {
   type WslServersState,
 } from "./wsl/types"
 export { ServerConnection } from "./context/server"
+export { createDraftStore, type DraftStore } from "./utils/draft-store"

+ 171 - 0
packages/app/src/utils/draft-store.ts

@@ -0,0 +1,171 @@
+import type { AsyncStorage } from "@solid-primitives/storage"
+
+export type BlobReference = { id: string; url: string }
+
+type Driver = {
+  get(key: string): Promise<string | null>
+  set(key: string, value: string): Promise<void>
+  remove(key: string): Promise<void>
+  putBlob(blob: Blob): Promise<string>
+  getBlob(id: string): Promise<Blob | null>
+}
+
+export type DraftStore = AsyncStorage & { putBlob(blob: Blob): Promise<BlobReference> }
+const urls = new Map<string, string>()
+
+function blobUrl(id: string, blob: Blob) {
+  const existing = urls.get(id)
+  if (existing) return existing
+  const url = URL.createObjectURL(blob)
+  urls.set(id, url)
+  return url
+}
+
+async function blobID(blob: Blob) {
+  const id = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await blob.arrayBuffer())))
+    .map((byte) => byte.toString(16).padStart(2, "0"))
+    .join("")
+  return id
+}
+
+export async function createBlobReference(blob: Blob): Promise<BlobReference> {
+  const id = await blobID(blob)
+  return { id, url: blobUrl(id, blob) }
+}
+
+export function createDraftStore(driver: Driver): DraftStore {
+  const versions = new Map<string, number>()
+  const putBlob = async (blob: Blob) => {
+    const id = await driver.putBlob(blob)
+    return { id, url: blobUrl(id, blob) }
+  }
+  const encode = async (value: unknown): Promise<unknown> => {
+    if (Array.isArray(value)) return Promise.all(value.map(encode))
+    if (!value || typeof value !== "object") return value
+    const item = value as Record<string, unknown>
+    if (item.type === "image" && typeof item.dataUrl === "string") {
+      const blob = await fetch(item.dataUrl).then((response) => response.blob())
+      const { dataUrl: _, ...rest } = item
+      return { ...rest, blob: { id: await driver.putBlob(blob) } }
+    }
+    if ("blob" in item && item.blob && typeof item.blob === "object") {
+      const blob = item.blob as Record<string, unknown>
+      if (typeof blob.id === "string" && blob.id.startsWith("data:")) {
+        const data = await fetch(blob.id).then((response) => response.blob())
+        return { ...item, blob: { id: await driver.putBlob(data) } }
+      }
+      return { ...item, blob: { id: blob.id } }
+    }
+    return Object.fromEntries(
+      await Promise.all(Object.entries(item).map(async ([key, entry]) => [key, await encode(entry)])),
+    )
+  }
+  const decode = async (value: unknown): Promise<unknown> => {
+    if (Array.isArray(value)) return Promise.all(value.map(decode))
+    if (!value || typeof value !== "object") return value
+    const item = value as Record<string, unknown>
+    if (item.blob && typeof item.blob === "object") {
+      const ref = item.blob as Record<string, unknown>
+      if (typeof ref.id === "string") {
+        const blob = await driver.getBlob(ref.id)
+        if (blob) return { ...item, blob: { id: ref.id, url: blobUrl(ref.id, blob) } }
+      }
+    }
+    return Object.fromEntries(
+      await Promise.all(Object.entries(item).map(async ([key, entry]) => [key, await decode(entry)])),
+    )
+  }
+  return {
+    getItem: async (key) => {
+      const value = await driver.get(key)
+      return value === null ? null : JSON.stringify(await decode(JSON.parse(value)))
+    },
+    setItem: async (key, value) => {
+      const version = (versions.get(key) ?? 0) + 1
+      versions.set(key, version)
+      const encoded = JSON.stringify(await encode(JSON.parse(value)))
+      if (versions.get(key) === version) await driver.set(key, encoded)
+    },
+    removeItem: async (key) => {
+      versions.set(key, (versions.get(key) ?? 0) + 1)
+      await driver.remove(key)
+    },
+    putBlob,
+  }
+}
+
+export function createBrowserDraftStore(): DraftStore {
+  const request = indexedDB.open("opencode-drafts", 1)
+  request.addEventListener("upgradeneeded", () => {
+    request.result.createObjectStore("documents")
+    request.result.createObjectStore("blobs")
+  })
+  const db = new Promise<IDBDatabase>((resolve, reject) => {
+    request.addEventListener("success", () => {
+      const database = request.result
+      const transaction = database.transaction(["documents", "blobs"], "readwrite")
+      const documents = transaction.objectStore("documents").getAll()
+      documents.addEventListener("success", () => {
+        const used = new Set<string>()
+        JSON.parse(`[${documents.result.join(",")}]`, (_key, item) => {
+          if (item?.blob && typeof item.blob.id === "string") used.add(item.blob.id)
+          return item
+        })
+        const blobs = transaction.objectStore("blobs").openKeyCursor()
+        blobs.addEventListener("success", () => {
+          const cursor = blobs.result
+          if (!cursor) return
+          if (!used.has(String(cursor.key))) cursor.delete()
+          cursor.continue()
+        })
+      })
+      transaction.addEventListener("complete", () => resolve(database))
+      transaction.addEventListener("abort", () => resolve(database))
+    })
+    request.addEventListener("error", () => reject(request.error))
+  })
+  const get = async (store: string, key: string) => {
+    const result = (await db).transaction(store).objectStore(store).get(key)
+    return new Promise<unknown>((resolve, reject) => {
+      result.addEventListener("success", () => resolve(result.result))
+      result.addEventListener("error", () => reject(result.error))
+    })
+  }
+  const write = async (store: string, key: string, value?: unknown) => {
+    const transaction = (await db).transaction(store, "readwrite")
+    if (value === undefined) transaction.objectStore(store).delete(key)
+    else transaction.objectStore(store).put(value, key)
+    return new Promise<void>((resolve, reject) => {
+      transaction.addEventListener("complete", () => resolve())
+      transaction.addEventListener("error", () => reject(transaction.error))
+    })
+  }
+  return createDraftStore({
+    get: async (key) => ((await get("documents", key)) as string | undefined) ?? null,
+    set: (key, value) => write("documents", key, value),
+    remove: (key) => write("documents", key),
+    putBlob: async (blob) => {
+      const id = await blobID(blob)
+      await write("blobs", id, blob)
+      return id
+    },
+    getBlob: async (id) => ((await get("blobs", id)) as Blob | undefined) ?? null,
+  })
+}
+
+export async function blobDataUrl(blob: BlobReference, mime: string) {
+  const data = await fetch(blob.url).then((response) => response.blob())
+  return new Promise<string>((resolve, reject) => {
+    const reader = new FileReader()
+    reader.addEventListener("error", () => reject(reader.error))
+    reader.addEventListener("load", () => {
+      const value = typeof reader.result === "string" ? reader.result : ""
+      resolve(`data:${mime};base64,${value.slice(value.indexOf(",") + 1)}`)
+    })
+    reader.readAsDataURL(data)
+  })
+}
+
+export function createLegacyBlobReference(dataUrl: string): BlobReference {
+  return { id: dataUrl, url: dataUrl }
+}

+ 47 - 5
packages/app/src/utils/persist.ts

@@ -15,6 +15,7 @@ type PersistedWithReady<T> = [
 ]
 
 type PersistTarget = {
+  draft?: boolean
   storage?: string
   scope?: "window"
   legacyStorageNames?: string[]
@@ -295,6 +296,14 @@ async function removeAsync(storage: AsyncStorage, key: string) {
   } catch {}
 }
 
+function toAsyncStorage(storage: SyncStorage | AsyncStorage): AsyncStorage {
+  return {
+    getItem: async (key) => storage.getItem(key),
+    setItem: async (key, value) => storage.setItem(key, value),
+    removeItem: async (key) => storage.removeItem(key),
+  }
+}
+
 async function migrateLegacyAsync(input: {
   current: AsyncStorage
   legacyStore?: AsyncStorage
@@ -513,6 +522,9 @@ export const Persist = {
     if (session) return Persist.serverSession(scope, dir, session, key, legacy)
     return Persist.serverWorkspace(scope, dir, key, legacy)
   },
+  prompt(target: PersistTarget): PersistTarget {
+    return { ...target, draft: true }
+  },
 }
 
 function resolveTarget(target: PersistTarget, platform: Platform): PersistTarget {
@@ -526,9 +538,12 @@ function resolveTarget(target: PersistTarget, platform: Platform): PersistTarget
 }
 
 export function removePersisted(
-  target: { storage?: string; legacyStorageNames?: string[]; key: string },
+  target: { draft?: boolean; storage?: string; legacyStorageNames?: string[]; key: string },
   platform?: Platform,
 ) {
+  if (target.draft && platform?.draftStore) {
+    void platform.draftStore.removeItem(`${target.storage ?? "default"}:${target.key}`)
+  }
   const isDesktop = platform?.platform === "desktop" && !!platform.storage
 
   if (isDesktop) {
@@ -561,8 +576,17 @@ export function persisted<T>(
   const legacy = config.legacy ?? []
 
   const isDesktop = platform.platform === "desktop" && !!platform.storage
+  const draft = config.draft ? platform.draftStore : undefined
 
   const currentStorage = (() => {
+    if (draft) {
+      const prefix = `${config.storage ?? "default"}:`
+      return {
+        getItem: (key: string) => draft.getItem(prefix + key),
+        setItem: (key: string, value: string) => draft.setItem(prefix + key, value),
+        removeItem: (key: string) => draft.removeItem(prefix + key),
+      } satisfies AsyncStorage
+    }
     if (isDesktop) return platform.storage?.(config.storage)
     if (!config.storage) return localStorageDirect()
     return localStorageWithPrefix(config.storage)
@@ -577,7 +601,7 @@ export function persisted<T>(
   const legacyStorageNames = config.legacyStorageNames ?? []
 
   const storage = (() => {
-    if (!isDesktop) {
+    if (!isDesktop && !draft) {
       const current = currentStorage as SyncStorage
       const legacyStore = legacyStorage as SyncStorage
       const legacyStores = legacyStorageNames.map(localStorageWithPrefix)
@@ -609,15 +633,26 @@ export function persisted<T>(
 
     const current = currentStorage as AsyncStorage
     const legacyStore = legacyStorage as AsyncStorage | undefined
-    const legacyStores = legacyStorageNames
-      .map((name) => platform.storage?.(name) as AsyncStorage | undefined)
+    const oldCurrent = draft
+      ? isDesktop
+        ? platform.storage?.(config.storage)
+        : config.storage
+          ? localStorageWithPrefix(config.storage)
+          : localStorageDirect()
+      : undefined
+    const legacyStores = [
+      oldCurrent,
+      ...legacyStorageNames.map((name) => (isDesktop ? platform.storage?.(name) : localStorageWithPrefix(name))),
+    ]
       .filter((x) => !!x)
+      .map(toAsyncStorage)
+    let draftLatest: string | undefined
 
     const api: AsyncStorage = {
       getItem: async (key) => {
         const value = await readCurrentAsync({ storage: current, key, defaults, migrate: config.migrate })
         if (value !== undefined) return value
-        return migrateLegacyAsync({
+        const migrated = await migrateLegacyAsync({
           current,
           legacyStore,
           stores: legacyStores,
@@ -626,8 +661,15 @@ export function persisted<T>(
           defaults,
           migrate: config.migrate,
         })
+        if (draftLatest === undefined) {
+          if (draft && migrated !== null) return (await current.getItem(key)) ?? migrated
+          return migrated
+        }
+        await current.setItem(key, draftLatest)
+        return draftLatest
       },
       setItem: async (key, value) => {
+        if (draft) draftLatest = value
         await current.setItem(key, value)
       },
       removeItem: async (key) => {

+ 12 - 2
packages/app/src/utils/prompt.test.ts

@@ -37,8 +37,18 @@ describe("extractPromptFromParts", () => {
     expect(result).toHaveLength(3)
     expect(result[0]).toMatchObject({ type: "text", content: "check these" })
     expect(result.slice(1)).toMatchObject([
-      { type: "image", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
-      { type: "image", filename: "b.pdf", mime: "application/pdf", dataUrl: "data:application/pdf;base64,BBB" },
+      {
+        type: "image",
+        filename: "a.png",
+        mime: "image/png",
+        blob: expect.objectContaining({ id: expect.any(String) }),
+      },
+      {
+        type: "image",
+        filename: "b.pdf",
+        mime: "application/pdf",
+        blob: expect.objectContaining({ id: expect.any(String) }),
+      },
     ])
   })
 })

+ 2 - 1
packages/app/src/utils/prompt.ts

@@ -1,5 +1,6 @@
 import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@opencode-ai/sdk/v2"
 import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
+import { createLegacyBlobReference } from "@/utils/draft-store"
 
 type Inline =
   | {
@@ -107,7 +108,7 @@ export function extractPromptFromParts(parts: Part[], opts?: { directory?: strin
           id: filePart.id,
           filename: filePart.filename ?? attachmentName,
           mime: filePart.mime,
-          dataUrl: filePart.url,
+          blob: createLegacyBlobReference(filePart.url),
         })
       }
     }

+ 49 - 1
packages/app/test-browser/prompt-persistence.test.ts

@@ -2,6 +2,7 @@ import { beforeAll, describe, expect, mock, test } from "bun:test"
 import type { AsyncStorage } from "@solid-primitives/storage"
 import { createEffect, createRoot } from "solid-js"
 import { ServerScope } from "@/utils/server-scope"
+import { createDraftStore } from "@/utils/draft-store"
 
 let Prompt: typeof import("@/context/prompt")
 let read: ((value: string | null) => void) | undefined
@@ -30,7 +31,7 @@ beforeAll(async () => {
     }),
   }))
   mock.module("@/context/platform", () => ({
-    usePlatform: () => ({ platform: "desktop", storage: () => storage }),
+    usePlatform: () => ({ platform: "desktop", storage: () => storage, draftStore: storage }),
   }))
 
   Prompt = await import("@/context/prompt")
@@ -69,3 +70,50 @@ describe("prompt persistence", () => {
     })
   })
 })
+
+test("moves legacy image data URLs into blobs and hydrates object URLs", async () => {
+  const documents = new Map<string, string>()
+  const blobs = new Map<string, Blob>()
+  const store = createDraftStore({
+    get: async (key) => documents.get(key) ?? null,
+    set: async (key, value) => void documents.set(key, value),
+    remove: async (key) => void documents.delete(key),
+    putBlob: async (blob) => {
+      const id = String(blob.size)
+      blobs.set(id, blob)
+      return id
+    },
+    getBlob: async (id) => blobs.get(id) ?? null,
+  })
+
+  await store.setItem("prompt", JSON.stringify({ prompt: [{ type: "image", dataUrl: "data:image/png;base64,YQ==" }] }))
+  expect(documents.get("prompt")).not.toContain("dataUrl")
+  const value = JSON.parse((await store.getItem("prompt"))!)
+  expect(value.prompt[0].blob.id).toBe("1")
+  expect(value.prompt[0].blob.url).toStartWith("blob:")
+})
+
+test("does not let delayed blob migration overwrite a newer draft", async () => {
+  const documents = new Map<string, string>()
+  const migration = Promise.withResolvers<void>()
+  const store = createDraftStore({
+    get: async () => null,
+    set: async (key, value) => void documents.set(key, value),
+    remove: async () => undefined,
+    putBlob: async () => {
+      await migration.promise
+      return "blob"
+    },
+    getBlob: async () => null,
+  })
+  const older = store.setItem(
+    "prompt",
+    JSON.stringify({ prompt: [{ type: "image", dataUrl: "data:image/png;base64,YQ==" }] }),
+  )
+  await Bun.sleep(0)
+  await store.setItem("prompt", JSON.stringify({ prompt: [{ type: "text", content: "latest" }] }))
+  migration.resolve()
+  await older
+
+  expect(documents.get("prompt")).toContain("latest")
+})

+ 2 - 1
packages/desktop/package.json

@@ -30,7 +30,8 @@
     "electron-log": "^5",
     "electron-store": "11.0.2",
     "electron-updater": "6.8.9",
-    "electron-window-state": "^5.0.3"
+    "electron-window-state": "^5.0.3",
+    "drizzle-orm": "catalog:"
   },
   "devDependencies": {
     "@actions/artifact": "4.0.0",

+ 16 - 0
packages/desktop/src/main/draft-store.test.ts

@@ -0,0 +1,16 @@
+import { expect, test } from "bun:test"
+import { createDesktopDraftStore } from "./draft-store"
+
+test("flushes the latest buffered draft and stores blobs", () => {
+  const store = createDesktopDraftStore(":memory:")
+  store.set("prompt", "first")
+  store.set("prompt", "latest")
+  expect(store.get("prompt")).toBe("latest")
+  store.flush()
+  expect(store.get("prompt")).toBe("latest")
+
+  const bytes = new TextEncoder().encode("image")
+  const id = store.putBlob(bytes)
+  expect(store.getBlob(id)).toEqual(bytes)
+  store.close()
+})

+ 82 - 0
packages/desktop/src/main/draft-store.ts

@@ -0,0 +1,82 @@
+import { createHash } from "node:crypto"
+import { DatabaseSync } from "node:sqlite"
+import { eq } from "drizzle-orm"
+import { drizzle } from "drizzle-orm/node-sqlite"
+import { blob, sqliteTable, text } from "drizzle-orm/sqlite-core"
+
+const documents = sqliteTable("document", {
+  key: text().primaryKey(),
+  value: text().notNull(),
+})
+const blobs = sqliteTable("blob", {
+  id: text().primaryKey(),
+  data: blob({ mode: "buffer" }).notNull(),
+})
+
+export function createDesktopDraftStore(filename: string) {
+  const native = new DatabaseSync(filename)
+  native.exec(
+    "PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS document (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS blob (id TEXT PRIMARY KEY, data BLOB NOT NULL);",
+  )
+  const db = drizzle({ client: native })
+  const used = new Set<string>()
+  db.select({ value: documents.value })
+    .from(documents)
+    .all()
+    .forEach(({ value }) =>
+      JSON.parse(value, (_key, item) => {
+        if (item?.blob && typeof item.blob.id === "string") used.add(item.blob.id)
+        return item
+      }),
+    )
+  db.select({ id: blobs.id })
+    .from(blobs)
+    .all()
+    .filter(({ id }) => !used.has(id))
+    .forEach(({ id }) => db.delete(blobs).where(eq(blobs.id, id)).run())
+  const pending = new Map<string, string | null>()
+  let timer: ReturnType<typeof setTimeout> | undefined
+  const flush = () => {
+    if (timer) clearTimeout(timer)
+    timer = undefined
+    const writes = [...pending]
+    pending.clear()
+    db.transaction((tx) => {
+      writes.forEach(([key, value]) => {
+        if (value === null) tx.delete(documents).where(eq(documents.key, key)).run()
+        else
+          tx.insert(documents)
+            .values({ key, value })
+            .onConflictDoUpdate({ target: documents.key, set: { value } })
+            .run()
+      })
+    })
+  }
+  const schedule = () => {
+    if (!timer) timer = setTimeout(flush, 500)
+  }
+  return {
+    get: (key: string) =>
+      pending.has(key)
+        ? (pending.get(key) ?? null)
+        : (db.select({ value: documents.value }).from(documents).where(eq(documents.key, key)).get()?.value ?? null),
+    set(key: string, value: string | null) {
+      pending.set(key, value)
+      schedule()
+    },
+    putBlob(data: Uint8Array) {
+      const id = createHash("sha256").update(data).digest("hex")
+      db.insert(blobs)
+        .values({ id, data: Buffer.from(data) })
+        .onConflictDoNothing()
+        .run()
+      return id
+    },
+    getBlob: (id: string) => db.select({ data: blobs.data }).from(blobs).where(eq(blobs.id, id)).get()?.data ?? null,
+    flush,
+    close() {
+      flush()
+      native.close()
+    },
+  }
+}

+ 2 - 2
packages/desktop/src/main/index.ts

@@ -171,7 +171,7 @@ const main = Effect.gen(function* () {
     setAppQuitting()
     void stopSidecars().finally(() => {
       app.relaunch()
-      app.exit(0)
+      app.quit()
     })
   }
 
@@ -245,7 +245,7 @@ const main = Effect.gen(function* () {
   for (const signal of ["SIGINT", "SIGTERM"] as const) {
     process.on(signal, () => {
       setAppQuitting()
-      void stopSidecars().finally(() => app.exit(0))
+      void stopSidecars().finally(() => app.quit())
     })
   }
 

+ 14 - 1
packages/desktop/src/main/ipc.ts

@@ -1,6 +1,6 @@
 import { execFile } from "node:child_process"
 import { stat } from "node:fs/promises"
-import { basename } from "node:path"
+import { basename, join } from "node:path"
 import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron"
 import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
 import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
@@ -21,6 +21,7 @@ import {
 } from "./windows"
 import type { UpdaterController } from "./updater-controller"
 import { createUpdaterSubscriptions } from "./updater-subscriptions"
+import { createDesktopDraftStore } from "./draft-store"
 
 const pickerFilters = (ext?: string[]) => {
   if (!ext || ext.length === 0) return undefined
@@ -51,8 +52,12 @@ type Deps = {
 }
 
 export function registerIpcHandlers(deps: Deps) {
+  const drafts = createDesktopDraftStore(join(app.getPath("userData"), "drafts.sqlite"))
   const updaterSubscriptions = createUpdaterSubscriptions()
   app.once("will-quit", updaterSubscriptions.clear)
+  app.on("before-quit", () => drafts.flush())
+  app.once("will-quit", () => drafts.close())
+  app.on("browser-window-created", (_event, win) => win.on("session-end", () => drafts.flush()))
 
   ipcMain.handle("kill-sidecar", () => deps.killSidecar())
   ipcMain.handle("await-initialization", () => deps.awaitInitialization())
@@ -123,6 +128,14 @@ export function registerIpcHandlers(deps: Deps) {
     const store = getStore(name)
     return Object.keys(store.store).length
   })
+  ipcMain.handle("draft-get", (_event, key: string) => drafts.get(key))
+  ipcMain.handle("draft-set", (_event, key: string, value: string) => drafts.set(key, value))
+  ipcMain.handle("draft-delete", (_event, key: string) => drafts.set(key, null))
+  ipcMain.handle("draft-blob-put", (_event, data: ArrayBuffer) => drafts.putBlob(new Uint8Array(data)))
+  ipcMain.handle("draft-blob-get", (_event, id: string) => {
+    const data = drafts.getBlob(id)
+    return data ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) : null
+  })
 
   ipcMain.handle(
     "open-directory-picker",

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

@@ -73,6 +73,11 @@ const api: ElectronAPI = {
   storeClear: (name) => ipcRenderer.invoke("store-clear", name),
   storeKeys: (name) => ipcRenderer.invoke("store-keys", name),
   storeLength: (name) => ipcRenderer.invoke("store-length", name),
+  draftGet: (key) => ipcRenderer.invoke("draft-get", key),
+  draftSet: (key, value) => ipcRenderer.invoke("draft-set", key, value),
+  draftDelete: (key) => ipcRenderer.invoke("draft-delete", key),
+  draftBlobPut: (data) => ipcRenderer.invoke("draft-blob-put", data),
+  draftBlobGet: (id) => ipcRenderer.invoke("draft-blob-get", id),
 
   getWindowID: () => ipcRenderer.invoke("get-window-id"),
   onMenuCommand: (cb) => {

+ 5 - 0
packages/desktop/src/preload/types.ts

@@ -63,6 +63,11 @@ export type ElectronAPI = {
   storeClear: (name: string) => Promise<void>
   storeKeys: (name: string) => Promise<string[]>
   storeLength: (name: string) => Promise<number>
+  draftGet: (key: string) => Promise<string | null>
+  draftSet: (key: string, value: string) => Promise<void>
+  draftDelete: (key: string) => Promise<void>
+  draftBlobPut: (data: ArrayBuffer) => Promise<string>
+  draftBlobGet: (id: string) => Promise<ArrayBuffer | null>
 
   getWindowID: () => Promise<string>
   onMenuCommand: (cb: (id: string) => void) => () => void

+ 8 - 0
packages/desktop/src/renderer/index.tsx

@@ -9,6 +9,7 @@ import {
   type Locale,
   type Platform,
   PlatformProvider,
+  createDraftStore,
   ServerConnection,
   useCommand,
   useWslServers,
@@ -226,6 +227,13 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
     },
 
     storage,
+    draftStore: createDraftStore({
+      get: window.api.draftGet,
+      set: window.api.draftSet,
+      remove: window.api.draftDelete,
+      putBlob: (blob) => blob.arrayBuffer().then(window.api.draftBlobPut),
+      getBlob: (id) => window.api.draftBlobGet(id).then((data) => data && new Blob([data])),
+    }),
 
     updater: {
       state: updaterState,

+ 11 - 17
packages/session-ui/src/v2/components/prompt-input/attachments.ts

@@ -78,6 +78,7 @@ export type PromptInputV2AttachmentConfig = {
   onError: (error: unknown) => void
   readClipboardImage?: () => Promise<File | null>
   getPathForFile?: (file: File) => string
+  store?: (file: File) => Promise<{ id: string; url: string }>
 }
 
 export function createPromptInputV2Attachments(
@@ -102,8 +103,7 @@ export function createPromptInputV2Attachments(
       if (toast) input.warn()
       return false
     }
-    const url = await dataUrl(file, mime)
-    if (!url) return false
+    const blob = input.store ? await input.store(file) : await blobReference(file)
     const sourcePath = input.getPathForFile?.(file) || undefined
     // Native clipboard images arrive with a fresh timestamped filename on every paste, so identical
     // clipboard content is matched on bytes alone.
@@ -112,7 +112,7 @@ export function createPromptInputV2Attachments(
       .some(
         (part) =>
           part.type === "image" &&
-          part.dataUrl === url &&
+          part.blob.id === blob.id &&
           (sourcePath
             ? part.sourcePath === sourcePath
             : !part.sourcePath && (clipboard || part.filename === file.name)),
@@ -127,7 +127,7 @@ export function createPromptInputV2Attachments(
       filename: file.name,
       sourcePath,
       mime,
-      dataUrl: url,
+      blob,
     }
     target.prompt.set([...target.prompt.current(), attachment], target.cursor)
     return true
@@ -219,20 +219,14 @@ export function createPromptInputV2Attachments(
   }
 }
 
-function dataUrl(file: File, mime: string) {
-  return new Promise<string>((resolve) => {
-    const reader = new FileReader()
-    reader.addEventListener("error", () => resolve(""))
-    reader.addEventListener("load", () => {
-      const value = typeof reader.result === "string" ? reader.result : ""
-      const index = value.indexOf(",")
-      resolve(index === -1 ? value : `data:${mime};base64,${value.slice(index + 1)}`)
-    })
-    reader.readAsDataURL(file)
-  })
-}
-
 const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
+
+async function blobReference(file: File) {
+  const id = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await file.arrayBuffer())))
+    .map((byte) => byte.toString(16).padStart(2, "0"))
+    .join("")
+  return { id, url: URL.createObjectURL(file) }
+}
 const imageExtensions = new Map([
   ["gif", "image/gif"],
   ["jpeg", "image/jpeg"],

+ 1 - 1
packages/session-ui/src/v2/components/prompt-input/index.tsx

@@ -425,7 +425,7 @@ export function PromptInputV2Attachments(props: {
                     }
                   >
                     <img
-                      src={attachment.dataUrl}
+                      src={attachment.blob.url}
                       alt={attachment.filename}
                       class="w-[58px] h-[46px] rounded-[6px] object-cover"
                       onClick={() => props.onAttachmentClick?.(attachment)}

+ 2 - 2
packages/session-ui/src/v2/components/prompt-input/prompt-input.stories.tsx

@@ -121,7 +121,7 @@ function ControlledPromptInput() {
         id: "attachment-1",
         filename: "requirements.md",
         mime: "text/markdown",
-        dataUrl: "data:text/markdown;base64,IyBSZXF1aXJlbWVudHM=",
+        blob: { id: "requirements", url: "data:text/markdown;base64,IyBSZXF1aXJlbWVudHM=" },
       },
     ],
     cursor: 0,
@@ -199,7 +199,7 @@ function ControlledPromptInput() {
       id: `attachment-${store.state.prompt.filter((part) => part.type === "image").length + 1}`,
       filename,
       mime,
-      dataUrl: `data:${mime};base64,`,
+      blob: { id: filename, url: `data:${mime};base64,` },
     })
   }
 

+ 2 - 2
packages/session-ui/src/v2/components/prompt-input/store.test.ts

@@ -13,7 +13,7 @@ function createPromptStore() {
           id: "attachment-1",
           filename: "notes.txt",
           mime: "text/plain",
-          dataUrl: "data:text/plain;base64,",
+          blob: { id: "a", url: "blob:a" },
         },
       ],
       cursor: 3,
@@ -50,7 +50,7 @@ describe("prompt input v2 store", () => {
         id: "attachment-1",
         filename: "notes.txt",
         mime: "text/plain",
-        dataUrl: "data:text/plain;base64,",
+        blob: { id: "a", url: "blob:a" },
       },
     ])
     expect(prompt.state.cursor).toBe(7)

+ 1 - 1
packages/session-ui/src/v2/components/prompt-input/types.ts

@@ -31,7 +31,7 @@ export type PromptInputV2Attachment = {
   filename: string
   sourcePath?: string
   mime: string
-  dataUrl: string
+  blob: { id: string; url: string }
 }
 
 export type PromptInputV2Prompt = (