diff --git a/bun.lock b/bun.lock
index cc50e910ec8e..e905ada8c512 100644
--- a/bun.lock
+++ b/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",
diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx
index 2d6a5e98d68d..96b99aaf4a2a 100644
--- a/packages/app/src/components/prompt-input-v2.tsx
+++ b/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(() => ),
+ dialog.show(() => ),
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,
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index 7923e18ee2ae..08c1ee348913 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -1489,7 +1489,7 @@ export const PromptInput: Component = (props) => {
- dialog.show(() => )
+ dialog.show(() => )
}
onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")}
diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts
index 9568b17d1927..6f3ef57c66e2 100644
--- a/packages/app/src/components/prompt-input/attachments.ts
+++ b/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((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["capture"]>, "current" | "cursor" | "set">
type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined }
@@ -36,6 +21,7 @@ type PromptAttachmentsCoreInput = {
warn?: () => void
readClipboardImage?: () => Promise
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({
diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts
index 7f0fea2bfe79..a1e3448b3117 100644
--- a/packages/app/src/components/prompt-input/build-request-parts.ts
+++ b/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 & { dataUrl: string })[]
text: string
messageID: string
sessionID: string
diff --git a/packages/app/src/components/prompt-input/history-store.ts b/packages/app/src/components/prompt-input/history-store.ts
index b64636f3c0b6..3858b8b9bc61 100644
--- a/packages/app/src/components/prompt-input/history-store.ts
+++ b/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({ 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({ 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))
+ },
+ }
}
diff --git a/packages/app/src/components/prompt-input/history.test.ts b/packages/app/src/components/prompt-input/history.test.ts
index 5e9c2c66eadc..94f24ce935ff 100644
--- a/packages/app/src/components/prompt-input/history.test.ts
+++ b/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)
diff --git a/packages/app/src/components/prompt-input/image-attachments.tsx b/packages/app/src/components/prompt-input/image-attachments.tsx
index 3a0cc3e4dd75..ecc38db88869 100644
--- a/packages/app/src/components/prompt-input/image-attachments.tsx
+++ b/packages/app/src/components/prompt-input/image-attachments.tsx
@@ -100,7 +100,7 @@ export const PromptImageAttachments: Component = (p
}
>
props.onOpen(attachment)}
diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts
index 051bf4d06cb9..c82c3a439939 100644
--- a/packages/app/src/components/prompt-input/submit.ts
+++ b/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" })
diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx
index 08686361c546..0408b233ffa8 100644
--- a/packages/app/src/context/platform.tsx
+++ b/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
diff --git a/packages/app/src/context/prompt-state.ts b/packages/app/src/context/prompt-state.ts
index 73196289e2f3..ea639a5bddf5 100644
--- a/packages/app/src/context/prompt-state.ts
+++ b/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) {
}
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
diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx
index e4d50ac16118..b6608e868112 100644
--- a/packages/app/src/context/tabs.tsx
+++ b/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) => {
diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx
index 6823d1c6d729..9b71d6567e74 100644
--- a/packages/app/src/entry.tsx
+++ b/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,
diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts
index 2267ebf08168..8d36086d9fc8 100644
--- a/packages/app/src/index.ts
+++ b/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"
diff --git a/packages/app/src/utils/draft-store.ts b/packages/app/src/utils/draft-store.ts
new file mode 100644
index 000000000000..cd0895f52ce9
--- /dev/null
+++ b/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
+ set(key: string, value: string): Promise
+ remove(key: string): Promise
+ putBlob(blob: Blob): Promise
+ getBlob(id: string): Promise
+}
+
+export type DraftStore = AsyncStorage & { putBlob(blob: Blob): Promise }
+const urls = new Map()
+
+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 {
+ const id = await blobID(blob)
+ return { id, url: blobUrl(id, blob) }
+}
+
+export function createDraftStore(driver: Driver): DraftStore {
+ const versions = new Map()
+ const putBlob = async (blob: Blob) => {
+ const id = await driver.putBlob(blob)
+ return { id, url: blobUrl(id, blob) }
+ }
+ const encode = async (value: unknown): Promise => {
+ if (Array.isArray(value)) return Promise.all(value.map(encode))
+ if (!value || typeof value !== "object") return value
+ const item = value as Record
+ 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
+ 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 => {
+ if (Array.isArray(value)) return Promise.all(value.map(decode))
+ if (!value || typeof value !== "object") return value
+ const item = value as Record
+ if (item.blob && typeof item.blob === "object") {
+ const ref = item.blob as Record
+ 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((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()
+ 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((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((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((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 }
+}
diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts
index a2daae4866bc..a7742c399e5e 100644
--- a/packages/app/src/utils/persist.ts
+++ b/packages/app/src/utils/persist.ts
@@ -15,6 +15,7 @@ type PersistedWithReady = [
]
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(
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(
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(
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(
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) => {
diff --git a/packages/app/src/utils/prompt.test.ts b/packages/app/src/utils/prompt.test.ts
index 1ecaf02c978e..8b86d43b9092 100644
--- a/packages/app/src/utils/prompt.test.ts
+++ b/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) }),
+ },
])
})
})
diff --git a/packages/app/src/utils/prompt.ts b/packages/app/src/utils/prompt.ts
index 35aec0071aac..67d32086bb56 100644
--- a/packages/app/src/utils/prompt.ts
+++ b/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),
})
}
}
diff --git a/packages/app/test-browser/prompt-persistence.test.ts b/packages/app/test-browser/prompt-persistence.test.ts
index a7b08078d7e9..f3d8f8ae4fbf 100644
--- a/packages/app/test-browser/prompt-persistence.test.ts
+++ b/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()
+ const blobs = new Map()
+ 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()
+ const migration = Promise.withResolvers()
+ 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")
+})
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index 582d4c9def11..6776591b3e28 100644
--- a/packages/desktop/package.json
+++ b/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",
diff --git a/packages/desktop/src/main/draft-store.test.ts b/packages/desktop/src/main/draft-store.test.ts
new file mode 100644
index 000000000000..95d4e7aa803b
--- /dev/null
+++ b/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()
+})
diff --git a/packages/desktop/src/main/draft-store.ts b/packages/desktop/src/main/draft-store.ts
new file mode 100644
index 000000000000..839eff26afe6
--- /dev/null
+++ b/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()
+ 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()
+ let timer: ReturnType | 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()
+ },
+ }
+}
diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts
index 556fd26d9666..e920b60257e2 100644
--- a/packages/desktop/src/main/index.ts
+++ b/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())
})
}
diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts
index f3208a70f342..bd7a3a75540c 100644
--- a/packages/desktop/src/main/ipc.ts
+++ b/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",
diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts
index 270ff4e576f0..6b8bd4dfa7da 100644
--- a/packages/desktop/src/preload/index.ts
+++ b/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) => {
diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts
index 3542a111914c..fbf58f1fd6dd 100644
--- a/packages/desktop/src/preload/types.ts
+++ b/packages/desktop/src/preload/types.ts
@@ -63,6 +63,11 @@ export type ElectronAPI = {
storeClear: (name: string) => Promise
storeKeys: (name: string) => Promise
storeLength: (name: string) => Promise
+ draftGet: (key: string) => Promise
+ draftSet: (key: string, value: string) => Promise
+ draftDelete: (key: string) => Promise
+ draftBlobPut: (data: ArrayBuffer) => Promise
+ draftBlobGet: (id: string) => Promise
getWindowID: () => Promise
onMenuCommand: (cb: (id: string) => void) => () => void
diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx
index 244b5eb405a3..e1ff8fbfc2e2 100644
--- a/packages/desktop/src/renderer/index.tsx
+++ b/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,
diff --git a/packages/session-ui/src/v2/components/prompt-input/attachments.ts b/packages/session-ui/src/v2/components/prompt-input/attachments.ts
index afa97a7f3e4f..89f38ec4a327 100644
--- a/packages/session-ui/src/v2/components/prompt-input/attachments.ts
+++ b/packages/session-ui/src/v2/components/prompt-input/attachments.ts
@@ -78,6 +78,7 @@ export type PromptInputV2AttachmentConfig = {
onError: (error: unknown) => void
readClipboardImage?: () => Promise
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((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"],
diff --git a/packages/session-ui/src/v2/components/prompt-input/index.tsx b/packages/session-ui/src/v2/components/prompt-input/index.tsx
index 4e2f3cfacf97..d29fd4df2350 100644
--- a/packages/session-ui/src/v2/components/prompt-input/index.tsx
+++ b/packages/session-ui/src/v2/components/prompt-input/index.tsx
@@ -425,7 +425,7 @@ export function PromptInputV2Attachments(props: {
}
>
props.onAttachmentClick?.(attachment)}
diff --git a/packages/session-ui/src/v2/components/prompt-input/prompt-input.stories.tsx b/packages/session-ui/src/v2/components/prompt-input/prompt-input.stories.tsx
index 525c2640975b..6c535b0284d7 100644
--- a/packages/session-ui/src/v2/components/prompt-input/prompt-input.stories.tsx
+++ b/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,` },
})
}
diff --git a/packages/session-ui/src/v2/components/prompt-input/store.test.ts b/packages/session-ui/src/v2/components/prompt-input/store.test.ts
index a58428d9c196..768a2224507a 100644
--- a/packages/session-ui/src/v2/components/prompt-input/store.test.ts
+++ b/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)
diff --git a/packages/session-ui/src/v2/components/prompt-input/types.ts b/packages/session-ui/src/v2/components/prompt-input/types.ts
index a2630a5ea6f2..c6b47a92ce17 100644
--- a/packages/session-ui/src/v2/components/prompt-input/types.ts
+++ b/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 = (