From 59882056be44fcc99f7b60c8d835868615bcde6c Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:04:04 +1000 Subject: [PATCH 1/2] fix(app): persist prompt drafts without base64 --- bun.lock | 1 + .../app/src/components/prompt-input-v2.tsx | 3 +- packages/app/src/components/prompt-input.tsx | 2 +- .../components/prompt-input/attachments.ts | 27 +--- .../prompt-input/build-request-parts.ts | 2 +- .../components/prompt-input/history-store.ts | 28 +++- .../components/prompt-input/history.test.ts | 2 +- .../prompt-input/image-attachments.tsx | 2 +- .../app/src/components/prompt-input/submit.ts | 29 ++-- packages/app/src/context/platform.tsx | 4 + packages/app/src/context/prompt-state.ts | 9 +- packages/app/src/context/tabs.tsx | 5 +- packages/app/src/entry.tsx | 2 + packages/app/src/index.ts | 1 + packages/app/src/utils/draft-store.ts | 151 ++++++++++++++++++ packages/app/src/utils/persist.ts | 52 +++++- packages/app/src/utils/prompt.test.ts | 14 +- packages/app/src/utils/prompt.ts | 3 +- .../test-browser/prompt-persistence.test.ts | 50 +++++- packages/desktop/package.json | 3 +- packages/desktop/src/main/draft-store.test.ts | 16 ++ packages/desktop/src/main/draft-store.ts | 82 ++++++++++ packages/desktop/src/main/index.ts | 4 +- packages/desktop/src/main/ipc.ts | 15 +- packages/desktop/src/preload/index.ts | 5 + packages/desktop/src/preload/types.ts | 5 + packages/desktop/src/renderer/index.tsx | 8 + .../v2/components/prompt-input/attachments.ts | 28 ++-- .../src/v2/components/prompt-input/index.tsx | 2 +- .../prompt-input/prompt-input.stories.tsx | 4 +- .../v2/components/prompt-input/store.test.ts | 4 +- .../src/v2/components/prompt-input/types.ts | 2 +- 32 files changed, 483 insertions(+), 82 deletions(-) create mode 100644 packages/app/src/utils/draft-store.ts create mode 100644 packages/desktop/src/main/draft-store.test.ts create mode 100644 packages/desktop/src/main/draft-store.ts 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 } > {attachment.filename} 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..135172b8f1e5 --- /dev/null +++ b/packages/app/src/utils/draft-store.ts @@ -0,0 +1,151 @@ +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", () => resolve(request.result)) + 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..f47b674a340c 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 ? Uint8Array.from(data).buffer : 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: { } > {attachment.filename} 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 = ( From 49078752ea5a1006d1a3dd18f11a6b7d7ff65614 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:36:51 +1000 Subject: [PATCH 2/2] fix(app): prune orphaned browser blobs --- packages/app/src/utils/draft-store.ts | 22 +++++++++++++++++++++- packages/desktop/src/main/ipc.ts | 2 +- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/app/src/utils/draft-store.ts b/packages/app/src/utils/draft-store.ts index 135172b8f1e5..cd0895f52ce9 100644 --- a/packages/app/src/utils/draft-store.ts +++ b/packages/app/src/utils/draft-store.ts @@ -101,7 +101,27 @@ export function createBrowserDraftStore(): DraftStore { request.result.createObjectStore("blobs") }) const db = new Promise((resolve, reject) => { - request.addEventListener("success", () => resolve(request.result)) + 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) => { diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index f47b674a340c..bd7a3a75540c 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -134,7 +134,7 @@ export function registerIpcHandlers(deps: Deps) { 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 ? Uint8Array.from(data).buffer : null + return data ? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) : null }) ipcMain.handle(