diff --git a/bun.lock b/bun.lock
index cc50e910ec8e..6fc04416c452 100644
--- a/bun.lock
+++ b/bun.lock
@@ -55,7 +55,6 @@
"@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/scheduled": "1.5.3",
"@solid-primitives/scroll": "2.1.3",
- "@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",
"@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:",
@@ -399,7 +398,6 @@
"@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@solid-primitives/i18n": "2.2.1",
- "@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.4",
"@types/bun": "catalog:",
@@ -1060,28 +1058,28 @@
},
},
"trustedDependencies": [
- "esbuild",
"tree-sitter-powershell",
- "protobufjs",
- "electron",
"web-tree-sitter",
"tree-sitter-bash",
+ "esbuild",
+ "electron",
+ "protobufjs",
],
"patchedDependencies": {
+ "solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
- "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
- "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
+ "pacote@21.5.0": "patches/pacote@21.5.0.patch",
+ "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
- "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
- "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
- "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
- "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
- "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
- "solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
- "pacote@21.5.0": "patches/pacote@21.5.0.patch",
+ "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
+ "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
+ "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
+ "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
+ "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
+ "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
},
"overrides": {
"@opentui/core": "catalog:",
@@ -2695,8 +2693,6 @@
"@solid-primitives/static-store": ["@solid-primitives/static-store@0.1.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-uxez7SXnr5GiRnzqO2IEDjOJRIXaG+0LZLBizmUA1FwSi+hrpuMzVBwyk70m4prcl8X6FDDXUl9O8hSq8wHbBQ=="],
- "@solid-primitives/storage": ["@solid-primitives/storage@4.3.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "@tauri-apps/plugin-store": "*", "solid-js": "^1.6.12", "solid-start": "*" }, "optionalPeers": ["@tauri-apps/plugin-store", "solid-start"] }, "sha512-ACbNwMZ1s8VAvld6EUXkDkX/US3IhtlPLxg6+B2s9MwNUugwdd51I98LPEaHrdLpqPmyzqgoJe0TxEFlf3Dqrw=="],
-
"@solid-primitives/timer": ["@solid-primitives/timer@1.4.4", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Ayjyb3+v1hyU92vuLUN0tVHq2mmTCPGxSDLGJMsDydRqx9ZfJIc9xj6cxK4XvdY3pif3ps2mIv52pjgToybEpQ=="],
"@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Za2JebEiDyfamjmDwRaESYqBBYOlgYGzB8kHYH0QrkXyLf2qNADlKdGN+z3vWSLCTDcKxChS43Kssjuc0OZhng=="],
diff --git a/packages/app/package.json b/packages/app/package.json
index 2dd4a05d86aa..3b80bfc162e3 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -71,7 +71,6 @@
"@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/scheduled": "1.5.3",
"@solid-primitives/scroll": "2.1.3",
- "@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",
"@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:",
diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx
index 5187d980ea26..994037f6e731 100644
--- a/packages/app/src/components/dialog-fork.tsx
+++ b/packages/app/src/components/dialog-fork.tsx
@@ -7,10 +7,11 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { showToast } from "@/utils/toast"
-import { extractPromptFromParts } from "@/utils/prompt"
+import { restorePromptFromParts } from "@/utils/prompt"
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { useLanguage } from "@/context/language"
+import { usePlatform } from "@/context/platform"
interface ForkableMessage {
id: string
@@ -30,6 +31,7 @@ export const DialogFork: Component = () => {
const prompt = usePrompt()
const dialog = useDialog()
const language = useLanguage()
+ const platform = usePlatform()
const messages = createMemo((): ForkableMessage[] => {
const sessionID = params.id
@@ -62,15 +64,20 @@ export const DialogFork: Component = () => {
if (!sessionID) return
const parts = sync().data.part[item.id] ?? []
- const restored = extractPromptFromParts(parts, {
+ const dir = base64Encode(sdk().directory)
+
+ void restorePromptFromParts(parts, {
directory: sdk().directory,
attachmentName: language.t("common.attachment"),
+ putBlob: (bytes) =>
+ platform.persistence?.putBlob(bytes) ?? Promise.reject(new Error("Attachment persistence is unavailable")),
})
- const dir = base64Encode(sdk().directory)
-
- sdk()
- .api.session.fork({ sessionID, messageID: item.id })
- .then((forked) => {
+ .then((restored) =>
+ sdk()
+ .api.session.fork({ sessionID, messageID: item.id })
+ .then((forked) => ({ forked, restored })),
+ )
+ .then(({ forked, restored }) => {
dialog.close()
prompt.set(restored, undefined, { dir, id: forked.id })
navigate(`/${dir}/session/${forked.id}`)
diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx
index 2d6a5e98d68d..5672397e66a7 100644
--- a/packages/app/src/components/prompt-input-v2.tsx
+++ b/packages/app/src/components/prompt-input-v2.tsx
@@ -343,8 +343,9 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
onContextRemove(item) {
if (item?.commentID) comments.remove(item.path, item.commentID)
},
- openAttachment: (attachment) =>
- dialog.show(() => ),
+ openAttachment: (attachment, previewUrl) => {
+ if (previewUrl) dialog.show(() => )
+ },
openContext(key) {
const item = controller.contextItem(key)
if (item) openComment(item, props, sync, layout, files, comments)
@@ -377,6 +378,11 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
}),
readClipboardImage: platform.readClipboardImage,
getPathForFile: platform.getPathForFile,
+ putBlob: (bytes) => {
+ if (!platform.persistence) return Promise.reject(new Error("Attachment persistence is unavailable"))
+ return platform.persistence.putBlob(bytes)
+ },
+ readBlob: (reference) => platform.persistence?.readBlob(reference) ?? Promise.resolve(null),
},
view: {
placeholder: designPlaceholder,
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index 7923e18ee2ae..2d099b225bbe 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -1160,7 +1160,7 @@ export const PromptInput: Component = (props) => {
return true
}
- const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
+ const { addAttachment, addAttachments, removeAttachment, handlePaste, previewUrl } = createPromptAttachments({
prompt,
editor: () => editorRef,
isDialogActive: () => !!dialog.active,
@@ -1172,6 +1172,11 @@ export const PromptInput: Component = (props) => {
addPart,
readClipboardImage: platform.readClipboardImage,
getPathForFile: platform.getPathForFile,
+ putBlob: (bytes) => {
+ if (!platform.persistence) return Promise.reject(new Error("Attachment persistence is unavailable"))
+ return platform.persistence.putBlob(bytes)
+ },
+ readBlob: (reference) => platform.persistence?.readBlob(reference) ?? Promise.resolve(null),
})
const fileAttachmentInput = () => (
@@ -1488,9 +1493,11 @@ export const PromptInput: Component = (props) => {
/>
- dialog.show(() => )
- }
+ previewUrl={previewUrl}
+ onOpen={(attachment) => {
+ const src = previewUrl(attachment)
+ if (src) dialog.show(() => )
+ }}
onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")}
newLayoutDesigns={false}
diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts
index 9568b17d1927..f68d666e11ed 100644
--- a/packages/app/src/components/prompt-input/attachments.ts
+++ b/packages/app/src/components/prompt-input/attachments.ts
@@ -1,30 +1,15 @@
-import { onMount } from "solid-js"
+import { createEffect, onCleanup, onMount } from "solid-js"
+import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { showToast } from "@/utils/toast"
import { type ContentPart, type ImageAttachmentPart, type usePrompt } from "@/context/prompt"
+import type { BlobReference } from "@/persistence"
import { useLanguage } from "@/context/language"
import { uuid } from "@/utils/uuid"
import { getCursorPosition } from "./editor-dom"
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,8 @@ type PromptAttachmentsCoreInput = {
warn?: () => void
readClipboardImage?: () => Promise
getPathForFile?: (file: File) => string
+ putBlob: (bytes: Uint8Array) => Promise
+ readBlob: (reference: BlobReference) => Promise
}
export type PromptAttachmentsInput = {
@@ -47,9 +34,102 @@ export type PromptAttachmentsInput = {
addPart: (part: ContentPart) => boolean
readClipboardImage?: () => Promise
getPathForFile?: (file: File) => string
+ putBlob: (bytes: Uint8Array) => Promise
+ readBlob: (reference: BlobReference) => Promise
}
export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
+ const [previews, setPreviews] = createStore>({})
+ const loading = new Set()
+ const migrating = new Set()
+ const revokePreview = (digest: string) => {
+ const url = previews[digest]
+ if (url) URL.revokeObjectURL(url)
+ setPreviews(digest, undefined)
+ loading.delete(digest)
+ }
+ const cachePreview = (attachment: ImageAttachmentPart, bytes: Uint8Array) => {
+ const reference = attachmentReference(attachment)
+ if (!reference) return
+ const previous = previews[reference.digest]
+ const next = URL.createObjectURL(new Blob([bytes.slice().buffer], { type: attachment.mime }))
+ setPreviews(reference.digest, next)
+ loading.delete(reference.digest)
+ if (previous) URL.revokeObjectURL(previous)
+ }
+ const previewUrl = (attachment: ImageAttachmentPart) => {
+ const reference = attachmentReference(attachment)
+ if (!reference) return
+ const digest = reference.digest
+ const current = previews[digest]
+ if (current || loading.has(digest)) return current
+ loading.add(digest)
+ void input
+ .readBlob(reference)
+ .then((bytes) => {
+ if (!bytes || previews[digest]) {
+ loading.delete(digest)
+ return
+ }
+ if (
+ !input
+ .capture()
+ .current()
+ .some((part) => part.type === "image" && attachmentReference(part)?.digest === digest)
+ ) {
+ loading.delete(digest)
+ return
+ }
+ cachePreview(attachment, bytes)
+ })
+ .catch(() => loading.delete(digest))
+ return previews[digest]
+ }
+ createEffect(() => {
+ const target = input.capture()
+ target.current().forEach((part) => {
+ if (part.type !== "image") return
+ const url = legacyAttachmentUrl(part)
+ if (!url || migrating.has(part.id)) return
+ migrating.add(part.id)
+ void fetch(url)
+ .then((response) => response.arrayBuffer())
+ .then((buffer) => {
+ const bytes = new Uint8Array(buffer)
+ return input.putBlob(bytes).then((blob) => ({ bytes, blob }))
+ })
+ .then(({ bytes, blob }) => {
+ const current = target.current()
+ if (!current.some((item) => item.type === "image" && item.id === part.id && legacyAttachmentUrl(item))) return
+ const attachment: ImageAttachmentPart = {
+ type: "image",
+ id: part.id,
+ filename: part.filename,
+ sourcePath: part.sourcePath,
+ mime: part.mime,
+ blob,
+ }
+ target.set(
+ current.map((item) => (item.type === "image" && item.id === part.id ? attachment : item)),
+ target.cursor(),
+ )
+ cachePreview(attachment, bytes)
+ })
+ .catch(() => {})
+ .finally(() => migrating.delete(part.id))
+ })
+ const active = new Set(
+ target.current().flatMap((part) => {
+ const reference = part.type === "image" ? attachmentReference(part) : undefined
+ return reference ? [reference.digest] : []
+ }),
+ )
+ Object.keys(previews).forEach((digest) => {
+ if (!active.has(digest)) revokePreview(digest)
+ })
+ })
+ onCleanup(() => Object.keys(previews).forEach(revokePreview))
+
const capture = (): AttachmentTarget | undefined => {
const prompt = input.capture()
const editor = input.editor()
@@ -65,8 +145,8 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
return false
}
- const url = await dataUrl(file, mime)
- if (!url) return false
+ const bytes = new Uint8Array(await file.arrayBuffer())
+ const blob = await input.putBlob(bytes)
const attachment: ImageAttachmentPart = {
type: "image",
@@ -74,9 +154,10 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
filename: file.name,
sourcePath: input.getPathForFile?.(file) || undefined,
mime,
- dataUrl: url,
+ blob,
}
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
+ cachePreview(attachment, bytes)
return true
}
@@ -103,6 +184,9 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
const removeAttachment = (id: string) => {
const target = input.capture()
const current = target.current()
+ const attachment = current.find((part): part is ImageAttachmentPart => part.type === "image" && part.id === id)
+ const reference = attachment ? attachmentReference(attachment) : undefined
+ if (reference) revokePreview(reference.digest)
const next = current.filter((part) => part.type !== "image" || part.id !== id)
target.set(next, target.cursor())
}
@@ -160,10 +244,20 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
addAttachments,
addClipboardAttachment,
removeAttachment,
+ previewUrl,
handlePaste,
}
}
+function attachmentReference(attachment: ImageAttachmentPart) {
+ return (attachment as ImageAttachmentPart & { blob?: BlobReference }).blob
+}
+
+function legacyAttachmentUrl(attachment: ImageAttachmentPart) {
+ const value = (attachment as ImageAttachmentPart & { dataUrl?: unknown }).dataUrl
+ return typeof value === "string" && value.startsWith("data:") ? value : undefined
+}
+
export function createPromptAttachments(input: PromptAttachmentsInput) {
const language = useLanguage()
const attachments = createPromptAttachmentsCore({
diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts
index ab84cb6eae81..b6abce2bdefb 100644
--- a/packages/app/src/components/prompt-input/build-request-parts.test.ts
+++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts
@@ -21,7 +21,7 @@ describe("buildRequestParts", () => {
prompt,
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
images: [
- { type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
+ { type: "image", id: "img_1", filename: "a.png", mime: "image/png", blob: { digest: "a", byteLength: 3 } },
],
text: "hello @src/foo.ts @planner",
messageID: "msg_1",
@@ -54,13 +54,13 @@ describe("buildRequestParts", () => {
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
context: [],
images: [
- { type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
+ { type: "image", id: "img_1", filename: "a.png", mime: "image/png", blob: { digest: "a", byteLength: 3 } },
{
type: "image",
id: "img_2",
filename: "b.pdf",
mime: "application/pdf",
- dataUrl: "data:application/pdf;base64,BBB",
+ blob: { digest: "b", byteLength: 3 },
},
],
text: "check these",
@@ -69,7 +69,7 @@ describe("buildRequestParts", () => {
sessionDirectory: "/repo",
})
- const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:"))
+ const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("opencode-blob:"))
expect(files).toHaveLength(2)
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
@@ -86,7 +86,7 @@ describe("buildRequestParts", () => {
filename: "opencode.global.dat",
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
mime: "text/plain",
- dataUrl: "data:text/plain;base64,AAA",
+ blob: { digest: "external", byteLength: 3 },
},
],
text: "inspect this",
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..913172e7139c 100644
--- a/packages/app/src/components/prompt-input/build-request-parts.ts
+++ b/packages/app/src/components/prompt-input/build-request-parts.ts
@@ -5,6 +5,7 @@ import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { Identifier } from "@/utils/id"
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
+import { attachmentReferenceUrl } from "@/utils/prompt"
type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
@@ -27,6 +28,7 @@ type BuildRequestPartsInput = {
messageID: string
sessionID: string
sessionDirectory: string
+ attachmentUrls?: ReadonlyMap
}
const absolute = (directory: string, path: string) => {
@@ -199,7 +201,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
id: Identifier.ascending("part"),
type: "file",
mime: attachment.mime,
- url: attachment.dataUrl,
+ url: input.attachmentUrls?.get(attachment.blob.digest) ?? attachmentReferenceUrl(attachment.blob),
filename: attachment.sourcePath ?? attachment.filename,
} satisfies PromptRequestPart
})
diff --git a/packages/app/src/components/prompt-input/history.test.ts b/packages/app/src/components/prompt-input/history.test.ts
index 5e9c2c66eadc..8473e5ab281a 100644
--- a/packages/app/src/components/prompt-input/history.test.ts
+++ b/packages/app/src/components/prompt-input/history.test.ts
@@ -112,10 +112,13 @@ 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: { digest: "abc", byteLength: 3 } },
]
const copy = clonePromptParts(original)
expect(copy).not.toBe(original)
+ expect(copy[2]?.type === "image" ? copy[2].blob : undefined).not.toBe(
+ original[2]?.type === "image" ? original[2].blob : undefined,
+ )
expect(promptLength(copy)).toBe(12)
if (copy[1]?.type !== "file") throw new Error("expected file")
copy[1].selection!.startLine = 9
diff --git a/packages/app/src/components/prompt-input/history.ts b/packages/app/src/components/prompt-input/history.ts
index 79e8abc0d9eb..7b9840507680 100644
--- a/packages/app/src/components/prompt-input/history.ts
+++ b/packages/app/src/components/prompt-input/history.ts
@@ -34,7 +34,7 @@ export function canNavigateHistoryAtCursor(direction: "up" | "down", text: strin
export function clonePromptParts(prompt: Prompt): Prompt {
return prompt.map((part) => {
if (part.type === "text") return { ...part }
- if (part.type === "image") return { ...part }
+ if (part.type === "image") return { ...part, blob: { ...part.blob } }
if (part.type === "agent") return { ...part }
return {
...part,
diff --git a/packages/app/src/components/prompt-input/image-attachments.tsx b/packages/app/src/components/prompt-input/image-attachments.tsx
index 3a0cc3e4dd75..2b3df2524b34 100644
--- a/packages/app/src/components/prompt-input/image-attachments.tsx
+++ b/packages/app/src/components/prompt-input/image-attachments.tsx
@@ -14,6 +14,7 @@ type PromptCommentItem = ContextItem & { key: string }
type PromptImageAttachmentsProps = {
attachments: ImageAttachmentPart[]
onOpen: (attachment: ImageAttachmentPart) => void
+ previewUrl: (attachment: ImageAttachmentPart) => string | undefined
onRemove: (id: string) => void
removeLabel: string
newLayoutDesigns: boolean
@@ -100,7 +101,7 @@ export const PromptImageAttachments: Component = (p
}
>
props.onOpen(attachment)}
diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts
index b3201b3ef68a..c39bb7238329 100644
--- a/packages/app/src/components/prompt-input/submit.test.ts
+++ b/packages/app/src/components/prompt-input/submit.test.ts
@@ -21,6 +21,8 @@ const optimistic: Array<{
model: { providerID: string; modelID: string }
variant?: string
}
+ parts?: Array<{ type: string; url?: string }>
+ cleanup?: () => void
}> = []
const optimisticSeeded: boolean[] = []
const storedSessions: Record> = {}
@@ -32,6 +34,7 @@ const sentPrompts: string[] = []
const promptInputs: unknown[] = []
const sentCommands: unknown[] = []
const commands: Array<{ name: string }> = []
+const blobs = new Map()
let serverSessionSyncs = 0
let params: { id?: string } = {}
@@ -135,6 +138,7 @@ beforeAll(async () => {
mock.module("@opencode-ai/ui/toast", () => ({
Toast: { Region: () => null },
showToast: () => 0,
+ toaster: { dismiss: () => undefined },
}))
mock.module("@opencode-ai/core/util/encode", () => ({
@@ -217,6 +221,8 @@ beforeAll(async () => {
directory?: string
sessionID?: string
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
+ parts?: Array<{ type: string; url?: string }>
+ cleanup?: () => void
}) => {
optimistic.push(value)
optimisticSeeded.push(
@@ -265,6 +271,9 @@ beforeAll(async () => {
mock.module("@/context/platform", () => ({
usePlatform: () => ({
fetch: fetch,
+ persistence: {
+ readBlob: async (reference: { digest: string }) => blobs.get(reference.digest)?.slice() ?? null,
+ },
}),
}))
@@ -291,6 +300,7 @@ beforeEach(() => {
promptInputs.length = 0
sentCommands.length = 0
commands.length = 0
+ blobs.clear()
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
params = {}
search = {}
@@ -494,16 +504,68 @@ describe("prompt submit worktree selection", () => {
])
})
+ test("resolves attachment references to canonical data URLs at submission", async () => {
+ params = { id: "session-1" }
+ blobs.set("image-digest", new Uint8Array([0, 255, 16]))
+ promptValue = [
+ { type: "text", content: "inspect", start: 0, end: 7 },
+ {
+ type: "image",
+ id: "image-1",
+ filename: "image.png",
+ mime: "image/png",
+ blob: { digest: "image-digest", byteLength: 3 },
+ },
+ ]
+
+ const submit = createPromptSubmit({
+ prompt,
+ info: () => ({ id: "session-1" }),
+ imageAttachments: () => promptValue.filter((part) => part.type === "image"),
+ commentCount: () => 0,
+ autoAccept: () => false,
+ mode: () => "normal",
+ working: () => false,
+ editor: () => undefined,
+ queueScroll: () => undefined,
+ promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
+ addToHistory: () => undefined,
+ resetHistoryNavigation: () => undefined,
+ setMode: () => undefined,
+ setPopover: () => undefined,
+ })
+
+ await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
+ await Bun.sleep(0)
+
+ expect(promptInputs[0]).toMatchObject({
+ files: [{ uri: "data:image/png;base64,AP8Q", name: "image.png" }],
+ })
+ expect(optimistic[0]?.parts).toContainEqual(expect.objectContaining({ url: expect.stringMatching(/^blob:/) }))
+ expect(optimistic[0]?.cleanup).toBeFunction()
+ expect(JSON.stringify(promptInputs[0])).not.toContain("image-digest")
+ })
+
test("submits slash commands through the current session API", async () => {
params = { id: "session-1" }
variant = "high"
commands.push({ name: "review" })
- promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
+ blobs.set("command-file", new TextEncoder().encode("hello"))
+ promptValue = [
+ { type: "text", content: "/review staged changes", start: 0, end: 22 },
+ {
+ type: "image",
+ id: "command-file",
+ filename: "notes.txt",
+ mime: "text/plain",
+ blob: { digest: "command-file", byteLength: 5 },
+ },
+ ]
const submit = createPromptSubmit({
prompt,
info: () => ({ id: "session-1" }),
- imageAttachments: () => [],
+ imageAttachments: () => promptValue.filter((part) => part.type === "image"),
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
@@ -518,6 +580,7 @@ describe("prompt submit worktree selection", () => {
})
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
+ await Bun.sleep(0)
expect(sentCommands).toEqual([
{
@@ -527,7 +590,7 @@ describe("prompt submit worktree selection", () => {
arguments: "staged changes",
agent: "agent",
model: { id: "model", providerID: "provider", variant: "high" },
- files: [],
+ files: [{ uri: "data:text/plain;base64,aGVsbG8=", name: "notes.txt" }],
},
])
expect(serverSessionSyncs).toBe(0)
diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts
index 051bf4d06cb9..7a1fe2228c59 100644
--- a/packages/app/src/components/prompt-input/submit.ts
+++ b/packages/app/src/components/prompt-input/submit.ts
@@ -22,6 +22,9 @@ import { ScopedKey } from "@/utils/server-scope"
import { createPromptSubmissionState } from "./submission-state"
import { normalizeSessionInfo } from "@/utils/session"
import { Event } from "@opencode-ai/schema/event"
+import { usePlatform } from "@/context/platform"
+import type { BlobReference } from "@/persistence"
+import { attachmentReferenceUrl, rememberAttachmentDataUrl } from "@/utils/prompt"
type PendingPrompt = {
abort: AbortController
@@ -29,6 +32,7 @@ type PendingPrompt = {
}
const pending = new Map()
+type ReadBlob = (reference: BlobReference) => Promise
export type FollowupDraft = {
sessionID: string
@@ -48,12 +52,54 @@ type FollowupSendInput = {
messageID?: string
optimisticBusy?: boolean
before?: () => Promise | boolean
+ readBlob?: ReadBlob
}
const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
+async function resolveAttachmentFiles(images: ImageAttachmentPart[], readBlob?: ReadBlob) {
+ if (images.length > 0 && !readBlob) throw new Error("Attachment persistence is unavailable")
+ return Promise.all(
+ images.map(async (attachment) => {
+ const bytes = await readBlob!(attachment.blob)
+ if (!bytes) throw new Error(`Attachment blob not found: ${attachment.blob.digest}`)
+ if (bytes.byteLength !== attachment.blob.byteLength) {
+ throw new Error(`Attachment blob length mismatch: ${attachment.blob.digest}`)
+ }
+ const chunks: string[] = []
+ for (let offset = 0; offset < bytes.length; offset += 0x8000) {
+ chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)))
+ }
+ const uri = `data:${attachment.mime};base64,${btoa(chunks.join(""))}`
+ rememberAttachmentDataUrl(uri, attachment.blob)
+ return { attachment, uri, name: attachment.filename }
+ }),
+ )
+}
+
+async function createOptimisticAttachmentUrls(images: ImageAttachmentPart[], readBlob?: ReadBlob) {
+ if (images.length > 0 && !readBlob) throw new Error("Attachment persistence is unavailable")
+ const entries = await Promise.all(
+ images.map(async (attachment) => {
+ const bytes = await readBlob!(attachment.blob)
+ if (!bytes) throw new Error(`Attachment blob not found: ${attachment.blob.digest}`)
+ return [
+ attachment.blob.digest,
+ URL.createObjectURL(new Blob([bytes.slice().buffer], { type: attachment.mime })),
+ ] as const
+ }),
+ )
+ return {
+ urls: new Map(entries),
+ cleanup:
+ entries.length > 0
+ ? () => requestAnimationFrame(() => entries.forEach(([, url]) => URL.revokeObjectURL(url)))
+ : undefined,
+ }
+}
+
export async function sendFollowupDraft(input: FollowupSendInput) {
const text = draftText(input.draft.prompt)
const images = draftImages(input.draft.prompt)
@@ -84,6 +130,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
}
const messageID = Identifier.ascending("message")
+ const files = await resolveAttachmentFiles(images, input.readBlob)
await input.api.command({
sessionID: input.draft.sessionID,
id: messageID,
@@ -95,10 +142,7 @@ 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: files.map((file) => ({ uri: file.uri, name: file.name })),
})
return true
} catch (err) {
@@ -108,6 +152,9 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
}
const messageID = input.messageID ?? Identifier.ascending("message")
+ const optimisticAttachments = images.length
+ ? await createOptimisticAttachmentUrls(images, input.readBlob)
+ : { urls: new Map(), cleanup: undefined }
const { requestParts, optimisticParts } = buildRequestParts({
prompt: input.draft.prompt,
context: input.draft.context,
@@ -116,6 +163,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
sessionID: input.draft.sessionID,
messageID,
sessionDirectory: input.draft.sessionDirectory,
+ attachmentUrls: optimisticAttachments.urls,
})
const message: Message = {
@@ -133,6 +181,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
sessionID: input.draft.sessionID,
message,
parts: optimisticParts,
+ cleanup: optimisticAttachments.cleanup,
})
const remove = () =>
@@ -156,15 +205,28 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
return false
}
+ const files = await resolveAttachmentFiles(images, input.readBlob)
+ const urls = new Map(files.map((file) => [file.attachment.blob.digest, file.uri]))
+ const resolvedRequestParts = requestParts.map((part) => {
+ if (part.type !== "file") return part
+ const attachment = images.find(
+ (image) =>
+ attachmentReferenceUrl(image.blob) === part.url ||
+ optimisticAttachments.urls.get(image.blob.digest) === part.url,
+ )
+ if (!attachment) return part
+ const url = urls.get(attachment.blob.digest)
+ return url ? { ...part, url } : part
+ })
await input.api.prompt({
sessionID: input.draft.sessionID,
id: messageID,
agent: input.draft.agent,
model: input.draft.model,
variant: input.draft.variant,
- legacyParts: requestParts,
- text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
- files: requestParts.flatMap((part) => {
+ legacyParts: resolvedRequestParts,
+ text: resolvedRequestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
+ files: resolvedRequestParts.flatMap((part) => {
if (part.type !== "file") return []
const text = part.source?.text
return [
@@ -235,6 +297,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const params = useParams()
const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs()
+ const platform = usePlatform()
+ const persistence = platform.persistence
+ const readBlob = persistence ? (reference: BlobReference) => persistence.readBlob(reference) : undefined
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
const errorMessage = (err: unknown) => {
@@ -508,27 +573,25 @@ export function createPromptSubmit(input: PromptSubmitInput) {
clearInput()
const messageID = Identifier.ascending("message")
serverSync().session.set("session_status", session.id, { type: "busy" })
- sdk()
- .api.session.command({
+ const send = (files: Awaited>) =>
+ sdk().api.session.command({
sessionID: session.id,
id: messageID,
command: commandName,
arguments: args.join(" "),
agent,
model: { id: model.modelID, providerID: model.providerID, variant },
- files: images.map((attachment) => ({
- uri: attachment.dataUrl,
- name: attachment.filename,
- })),
+ files: files.map((file) => ({ uri: file.uri, name: file.name })),
})
- .catch((err) => {
- serverSync().session.set("session_status", session.id, { type: "idle" })
- showToast({
- title: language.t("prompt.toast.commandSendFailed.title"),
- description: formatServerError(err, language.t, language.t("common.requestFailed")),
- })
- restoreInput()
+ const request = images.length > 0 ? resolveAttachmentFiles(images, readBlob).then(send) : send([])
+ void request.catch((err) => {
+ serverSync().session.set("session_status", session.id, { type: "idle" })
+ showToast({
+ title: language.t("prompt.toast.commandSendFailed.title"),
+ description: formatServerError(err, language.t, language.t("common.requestFailed")),
})
+ restoreInput()
+ })
return
}
}
@@ -613,6 +676,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
messageID,
optimisticBusy: sessionDirectory === projectDirectory,
before: waitForWorktree,
+ readBlob,
}).catch((err) => {
pending.delete(pendingKey(session.id))
if (sessionDirectory === projectDirectory) {
diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts
index befd5b61e095..1d8510aa22f9 100644
--- a/packages/app/src/context/directory-sync.ts
+++ b/packages/app/src/context/directory-sync.ts
@@ -84,7 +84,7 @@ export const createDirSyncContext = (
if (session?.directory === directory) return session
},
optimistic: {
- add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
+ add(input: { directory?: string; sessionID: string; message: Message; parts: Part[]; cleanup?: () => void }) {
serverSync.session.optimistic.add(input)
},
remove(input: { directory?: string; sessionID: string; messageID: string }) {
diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx
index 08686361c546..6b15550051d6 100644
--- a/packages/app/src/context/platform.tsx
+++ b/packages/app/src/context/platform.tsx
@@ -1,7 +1,7 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
-import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
import type { Accessor } from "solid-js"
import type { DesktopMenuAction } from "../desktop-menu"
+import { getIndexedDBRepository, type Repository } from "../persistence"
import { ServerConnection } from "./server"
import type { WslServersPlatform } from "../wsl/types"
import type { UpdaterPlatform } from "../updater"
@@ -61,8 +61,8 @@ type PlatformBase = {
/** Open a native save file picker dialog (desktop only) */
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise
- /** Storage mechanism, defaults to localStorage */
- storage?: (name?: string) => SyncStorage | AsyncStorage
+ /** Durable app document and blob persistence */
+ persistence?: Repository
/** Stable platform window identity for window-scoped persistence */
windowID?: string
@@ -134,6 +134,7 @@ export type DisplayBackend = "auto" | "wayland"
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
name: "Platform",
init: (props: { value: Platform }) => {
- return props.value
+ if (props.value.persistence) return props.value
+ return { ...props.value, persistence: getIndexedDBRepository() }
},
})
diff --git a/packages/app/src/context/prompt-state.ts b/packages/app/src/context/prompt-state.ts
index 73196289e2f3..7197af60b2d9 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 "@/persistence"
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
@@ -101,7 +102,7 @@ function isPartEqual(partA: ContentPart, partB: ContentPart) {
case "agent":
return partB.type === "agent" && partA.name === partB.name
case "image":
- return partB.type === "image" && partA.id === partB.id
+ return partB.type === "image" && partA.blob?.digest === partB.blob?.digest
}
}
@@ -120,7 +121,7 @@ function cloneSelection(selection?: FileSelection) {
function clonePart(part: ContentPart): ContentPart {
if (part.type === "text") return { ...part }
- if (part.type === "image") return { ...part }
+ if (part.type === "image") return { ...part, blob: { ...part.blob } }
if (part.type === "agent") return { ...part }
return {
...part,
diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts
index e9595e137607..670bebda3066 100644
--- a/packages/app/src/context/server-session.test.ts
+++ b/packages/app/src/context/server-session.test.ts
@@ -21,6 +21,7 @@ const session = (id: string, parentID?: string): Session => ({
type UserMessage = Extract
type AssistantMessage = Extract
type TextPart = Extract
+type FilePart = Extract
type MessageResponse = {
data: { info: Message; parts: Part[] }[]
response: { headers: Headers }
@@ -902,7 +903,8 @@ describe("server session", () => {
const message = userMessage("message")
const part = textPart(message.id, { text: "optimistic" })
const store = setup({ child: session("child") }).store
- store.optimistic.add({ sessionID: "child", message, parts: [part] })
+ let cleaned = 0
+ store.optimistic.add({ sessionID: "child", message, parts: [part], cleanup: () => cleaned++ })
store.apply({
type: "message.part.delta",
properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" },
@@ -912,6 +914,7 @@ describe("server session", () => {
expect(store.data.part[message.id]).toBeUndefined()
expect(store.data.part_text_accum_delta[part.id]).toBeUndefined()
+ expect(cleaned).toBe(1)
})
test("does not remove content confirmed by a message event", () => {
@@ -931,7 +934,8 @@ describe("server session", () => {
const message = userMessage("message")
const part = textPart(message.id)
const store = setup({ child: session("child") }).store
- store.optimistic.add({ sessionID: "child", message, parts: [part] })
+ let cleaned = 0
+ store.optimistic.add({ sessionID: "child", message, parts: [part], cleanup: () => cleaned++ })
store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } })
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
@@ -939,6 +943,37 @@ describe("server session", () => {
expect(store.data.message.child).toEqual([message])
expect(store.data.part[message.id]).toEqual([part])
+ expect(cleaned).toBe(1)
+ })
+
+ test("replaces an optimistic attachment URL before releasing it", () => {
+ const message = userMessage("message")
+ const optimistic: FilePart = {
+ id: "file",
+ sessionID: "child",
+ messageID: message.id,
+ type: "file",
+ mime: "image/png",
+ filename: "image.png",
+ url: "blob:optimistic",
+ }
+ const confirmed = { ...optimistic, url: "data:image/png;base64,AA==" }
+ const store = setup({ child: session("child") }).store
+ let urlAtCleanup: string | undefined
+ store.optimistic.add({
+ sessionID: "child",
+ message,
+ parts: [optimistic],
+ cleanup: () => {
+ const part = store.data.part[message.id]?.[0]
+ urlAtCleanup = part?.type === "file" ? part.url : undefined
+ },
+ })
+ store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } })
+ store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: confirmed, time: 2 } })
+
+ expect(urlAtCleanup).toBe(confirmed.url)
+ expect(store.data.part[message.id]).toEqual([confirmed])
})
test("treats a part event as confirmation when it precedes the message event", () => {
diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts
index 69bec61ba2fc..b04e19802f30 100644
--- a/packages/app/src/context/server-session.ts
+++ b/packages/app/src/context/server-session.ts
@@ -49,6 +49,7 @@ type OptimisticItem = {
parts: Part[]
confirmedParts?: Part[]
confirmedMessage?: boolean
+ cleanup?: () => void
}
type MessagePage = {
@@ -351,11 +352,13 @@ export function createServerSession(
const clearOptimistic = (sessionID: string, messageID?: string) => {
if (!messageID) {
+ optimistic.get(sessionID)?.forEach((item) => item.cleanup?.())
optimistic.delete(sessionID)
return
}
const items = optimistic.get(sessionID)
if (!items) return
+ items.get(messageID)?.cleanup?.()
items.delete(messageID)
if (items.size === 0) optimistic.delete(sessionID)
}
@@ -1130,7 +1133,6 @@ export function createServerSession(
if (optimistic?.size === 0) load?.optimisticParts.delete(part.messageID)
deltaBases.delete(part.id)
trackPartChange(part.sessionID, part.messageID, part.id)
- confirmOptimisticPart(part.sessionID, part.messageID, part)
setData(
"part_text_accum_delta",
produce((draft) => void delete draft[part.id]),
@@ -1138,6 +1140,7 @@ export function createServerSession(
const parts = data.part[part.messageID]
if (!parts) {
setData("part", part.messageID, [part])
+ confirmOptimisticPart(part.sessionID, part.messageID, part)
return
}
const result = Binary.search(parts, part.id, (item) => item.id)
@@ -1148,6 +1151,7 @@ export function createServerSession(
next.splice(result.index, 0, part)
return next
})
+ confirmOptimisticPart(part.sessionID, part.messageID, part)
return
}
case "message.part.removed": {
@@ -1320,7 +1324,7 @@ export function createServerSession(
return Date.now() - (meta.at[sessionID] ?? 0) <= ttl
},
optimistic: {
- add(input: { sessionID: string; message: Message; parts: Part[] }) {
+ add(input: { sessionID: string; message: Message; parts: Part[]; cleanup?: () => void }) {
const parts = input.parts
.filter((part) => !!part?.id && !SKIP_PARTS.has(part.type))
.sort((a, b) => cmp(a.id, b.id))
@@ -1335,6 +1339,7 @@ export function createServerSession(
load.optimisticParts.set(input.message.id, new Set(parts.map((part) => part.id)))
}
const items = optimistic.get(input.sessionID)
+ items?.get(input.message.id)?.cleanup?.()
const removedMessagesForSession = removedMessages.get(input.sessionID)
removedMessagesForSession?.delete(input.message.id)
if (removedMessagesForSession?.size === 0) removedMessages.delete(input.sessionID)
diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts
index 2267ebf08168..4b094b642dce 100644
--- a/packages/app/src/index.ts
+++ b/packages/app/src/index.ts
@@ -27,3 +27,5 @@ export {
type WslServersState,
} from "./wsl/types"
export { ServerConnection } from "./context/server"
+export { createRepository } from "./persistence"
+export type { BlobReference, DocumentAddress, DocumentValue, DurableRepository, Repository } from "./persistence"
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index c6e3a5fcbb71..5dcb7b373b8d 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -97,7 +97,7 @@ import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { Identifier } from "@/utils/id"
import { diffs as list } from "@/utils/diffs"
import { Persist, persisted } from "@/utils/persist"
-import { extractPromptFromParts } from "@/utils/prompt"
+import { extractPromptFromParts, restorePromptFromParts } from "@/utils/prompt"
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
@@ -1673,6 +1673,13 @@ export default function Page() {
directory: sdk().directory,
attachmentName: language.t("common.attachment"),
})
+ const restoreDraft = (id: string) =>
+ restorePromptFromParts(sync().data.part[id] ?? [], {
+ directory: sdk().directory,
+ attachmentName: language.t("common.attachment"),
+ putBlob: (bytes) =>
+ platform.persistence?.putBlob(bytes) ?? Promise.reject(new Error("Attachment persistence is unavailable")),
+ })
const line = (id: string) => {
const text = draft(id)
@@ -1729,6 +1736,7 @@ export default function Page() {
serverSync: serverSync(),
draft: item,
optimisticBusy: item.sessionDirectory === sdk().directory,
+ readBlob: (reference) => platform.persistence?.readBlob(reference) ?? Promise.resolve(null),
}).catch((err) => {
setFollowup("failed", input.sessionID, input.id)
fail(err)
@@ -1829,7 +1837,7 @@ export default function Page() {
const session = sdk().api.session
const target = sync()
const last = target.session.get(input.sessionID)?.revert
- const value = draft(input.messageID)
+ const value = await restoreDraft(input.messageID)
await runPromptRollbackMutation({
capturePrompt: prompt.capture,
optimistic: (prompt) => {
@@ -1852,14 +1860,15 @@ export default function Page() {
const session = sdk().api.session
const target = sync()
const next = userMessages().find((item) => item.id > id)
+ const value = next ? await restoreDraft(next.id) : undefined
const last = target.session.get(sessionID)?.revert
await runPromptRollbackMutation({
capturePrompt: prompt.capture,
optimistic: (promptSession) => {
roll(sessionID, next ? { messageID: next.id } : undefined, target)
- if (next) {
- promptSession.set(draft(next.id))
+ if (next && value) {
+ promptSession.set(value)
return
}
promptSession.reset()
diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx
index 12dd96a5e66b..d06ccfb01693 100644
--- a/packages/app/src/pages/session/use-session-commands.tsx
+++ b/packages/app/src/pages/session/use-session-commands.tsx
@@ -14,11 +14,12 @@ import { useTerminal } from "@/context/terminal"
import { showToast } from "@/utils/toast"
import { findLast } from "@opencode-ai/core/util/array"
import { createSessionTabs } from "@/pages/session/helpers"
-import { extractPromptFromParts } from "@/utils/prompt"
+import { restorePromptFromParts } from "@/utils/prompt"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionOwnership } from "./session-ownership"
import { useLocal } from "@/context/local"
+import { usePlatform } from "@/context/platform"
export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void
@@ -48,6 +49,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const terminal = useTerminal()
const layout = useLayout()
const local = useLocal()
+ const platform = usePlatform()
const navigate = useNavigate()
const { params, sessionKey, tabs, view } = useSessionLayout()
const sessionOwnership = createSessionOwnership(sessionKey)
@@ -314,6 +316,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const message = findLast(messages, (x) => !revert || x.id < revert)
if (!message) return
const parts = sync().data.part[message.id]
+ const restored = parts
+ ? await restorePromptFromParts(parts, {
+ directory,
+ putBlob: (bytes) =>
+ platform.persistence?.putBlob(bytes) ?? Promise.reject(new Error("Attachment persistence is unavailable")),
+ })
+ : undefined
if (sync().data.session_working(sessionID)) {
await session.interrupt({ sessionID }).catch(() => {})
@@ -324,7 +333,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
prompt: promptSession,
request: () => session.revert.stage({ sessionID, messageID: message.id }),
updatePrompt: (promptSession) => {
- if (parts) promptSession.set(extractPromptFromParts(parts, { directory }))
+ if (restored) promptSession.set(restored)
},
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < message.id)),
})
diff --git a/packages/app/src/persistence/checkpoint.test.ts b/packages/app/src/persistence/checkpoint.test.ts
new file mode 100644
index 000000000000..2609f2767fd4
--- /dev/null
+++ b/packages/app/src/persistence/checkpoint.test.ts
@@ -0,0 +1,99 @@
+import { describe, expect, test, vi } from "bun:test"
+import { createCheckpointController } from "./checkpoint"
+
+const tick = async () => {
+ await Promise.resolve()
+ await Promise.resolve()
+}
+
+describe("checkpoint controller", () => {
+ test("checkpoints no later than 500ms from the first dirty mutation", async () => {
+ vi.useFakeTimers()
+ try {
+ const writes: string[] = []
+ const checkpoint = createCheckpointController(async (value) => {
+ writes.push(value)
+ })
+
+ checkpoint.checkpoint("first")
+ vi.advanceTimersByTime(250)
+ checkpoint.checkpoint("latest")
+ vi.advanceTimersByTime(249)
+ await tick()
+ expect(writes).toEqual([])
+
+ vi.advanceTimersByTime(1)
+ await tick()
+ expect(writes).toEqual(["latest"])
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ test("allows one commit in flight and coalesces to the latest state", async () => {
+ vi.useFakeTimers()
+ try {
+ const first = Promise.withResolvers()
+ const writes: string[] = []
+ const checkpoint = createCheckpointController((value) => {
+ writes.push(value)
+ return writes.length === 1 ? first.promise : Promise.resolve()
+ })
+
+ checkpoint.checkpoint("first")
+ vi.advanceTimersByTime(500)
+ await tick()
+ checkpoint.checkpoint("second")
+ checkpoint.checkpoint("latest")
+ vi.advanceTimersByTime(500)
+ await tick()
+ expect(writes).toEqual(["first"])
+
+ first.resolve()
+ await tick()
+ vi.advanceTimersByTime(0)
+ await tick()
+ expect(writes).toEqual(["first", "latest"])
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ test("keeps failed state dirty so drain retries it", async () => {
+ vi.useFakeTimers()
+ try {
+ const writes: string[] = []
+ const checkpoint = createCheckpointController((value) => {
+ writes.push(value)
+ return writes.length === 1 ? Promise.reject(new Error("failed")) : Promise.resolve()
+ })
+
+ checkpoint.checkpoint("state")
+ vi.advanceTimersByTime(500)
+ await tick()
+ expect(writes).toEqual(["state"])
+
+ await checkpoint.drain()
+ expect(writes).toEqual(["state", "state"])
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ test("drain waits only for the generation it observed", async () => {
+ const first = Promise.withResolvers()
+ const writes: string[] = []
+ const checkpoint = createCheckpointController((value) => {
+ writes.push(value)
+ return first.promise
+ })
+
+ checkpoint.checkpoint("observed")
+ const drained = checkpoint.drain()
+ checkpoint.checkpoint("later")
+ first.resolve()
+ await drained
+
+ expect(writes).toEqual(["observed"])
+ })
+})
diff --git a/packages/app/src/persistence/checkpoint.ts b/packages/app/src/persistence/checkpoint.ts
new file mode 100644
index 000000000000..979705f30738
--- /dev/null
+++ b/packages/app/src/persistence/checkpoint.ts
@@ -0,0 +1,108 @@
+const CHECKPOINT_INTERVAL = 500
+
+type Waiter = {
+ generation: number
+ resolve(): void
+ reject(error: unknown): void
+}
+
+export function createCheckpointController(commit: (value: T) => Promise) {
+ let generation = 0
+ let committed = 0
+ let attempted = 0
+ let latest: T
+ let dirtySince: number | undefined
+ let timer: ReturnType | undefined
+ let inFlight = false
+ let discarded = false
+ let waiters: Waiter[] = []
+ let idleWaiters: Array<() => void> = []
+
+ const schedule = () => {
+ if (timer !== undefined || inFlight || committed === generation) return
+ const delay = Math.max(0, CHECKPOINT_INTERVAL - (Date.now() - (dirtySince ?? Date.now())))
+ timer = setTimeout(() => {
+ timer = undefined
+ run()
+ }, delay)
+ }
+
+ const settle = () => {
+ const ready = waiters.filter((waiter) => waiter.generation <= committed)
+ waiters = waiters.filter((waiter) => waiter.generation > committed)
+ ready.forEach((waiter) => waiter.resolve())
+ }
+
+ const run = () => {
+ if (inFlight || committed === generation) return
+ inFlight = true
+ attempted = generation
+ const value = latest
+ const attemptedSince = dirtySince
+ dirtySince = undefined
+
+ void Promise.resolve()
+ .then(() => commit(value))
+ .then(
+ () => {
+ committed = attempted
+ inFlight = false
+ idleWaiters.splice(0).forEach((resolve) => resolve())
+ settle()
+ if (discarded) return
+ if (committed === generation) return
+ if (waiters.some((waiter) => waiter.generation > committed)) {
+ run()
+ return
+ }
+ schedule()
+ },
+ (error) => {
+ inFlight = false
+ idleWaiters.splice(0).forEach((resolve) => resolve())
+ dirtySince = Math.min(attemptedSince ?? Date.now(), dirtySince ?? Number.POSITIVE_INFINITY)
+ const pending = waiters
+ waiters = []
+ pending.forEach((waiter) => waiter.reject(error))
+ },
+ )
+ }
+
+ return {
+ checkpoint(value: T) {
+ latest = value
+ generation += 1
+ dirtySince ??= Date.now()
+ schedule()
+ },
+ drain() {
+ const observed = generation
+ if (committed >= observed) return Promise.resolve()
+ return new Promise((resolve, reject) => {
+ waiters.push({ generation: observed, resolve, reject })
+ if (timer !== undefined) {
+ clearTimeout(timer)
+ timer = undefined
+ }
+ run()
+ })
+ },
+ discard() {
+ discarded = true
+ if (timer !== undefined) clearTimeout(timer)
+ timer = undefined
+ generation = committed
+ dirtySince = undefined
+ const pending = waiters
+ waiters = []
+ pending.forEach((waiter) => waiter.resolve())
+ },
+ idle() {
+ if (!inFlight) return Promise.resolve()
+ return new Promise((resolve) => idleWaiters.push(resolve))
+ },
+ pending(): T | undefined {
+ return committed === generation ? undefined : latest
+ },
+ }
+}
diff --git a/packages/app/src/persistence/index.ts b/packages/app/src/persistence/index.ts
new file mode 100644
index 000000000000..413a64ac1fdc
--- /dev/null
+++ b/packages/app/src/persistence/index.ts
@@ -0,0 +1,4 @@
+export { createCheckpointController } from "./checkpoint"
+export { getIndexedDBRepository, IndexedDBRepository } from "./indexed-db"
+export { createRepository } from "./repository"
+export type { BlobReference, DocumentAddress, DocumentValue, DurableRepository, Repository } from "./repository"
diff --git a/packages/app/src/persistence/indexed-db.ts b/packages/app/src/persistence/indexed-db.ts
new file mode 100644
index 000000000000..32d8197d4c5e
--- /dev/null
+++ b/packages/app/src/persistence/indexed-db.ts
@@ -0,0 +1,145 @@
+import {
+ createRepository,
+ type BlobReference,
+ type DocumentAddress,
+ type DurableRepository,
+ type Repository,
+} from "./repository"
+
+const DATABASE = "opencode.persistence"
+const DOCUMENTS = "documents"
+const BLOBS = "blobs"
+
+type DocumentRecord = DocumentAddress & { value: string }
+type BlobRecord = BlobReference & { bytes: Uint8Array }
+
+function request(value: IDBRequest) {
+ return new Promise((resolve, reject) => {
+ value.onsuccess = () => resolve(value.result)
+ value.onerror = () => reject(value.error)
+ })
+}
+
+function transaction(value: IDBTransaction) {
+ return new Promise((resolve, reject) => {
+ value.oncomplete = () => resolve()
+ value.onabort = () => reject(value.error)
+ value.onerror = () => reject(value.error)
+ })
+}
+
+function key(address: DocumentAddress) {
+ return [address.storage, address.key]
+}
+
+function sha256(value: Uint8Array) {
+ const bytes = value.slice().buffer
+ return crypto.subtle
+ .digest("SHA-256", bytes)
+ .then((hash) => Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, "0")).join(""))
+}
+
+export class IndexedDBRepository implements DurableRepository {
+ private database: Promise | undefined
+ private readonly writes = new Set>()
+
+ constructor(private readonly name = DATABASE) {}
+
+ private open() {
+ this.database ??= new Promise((resolve, reject) => {
+ const opening = indexedDB.open(this.name, 1)
+ opening.onupgradeneeded = () => {
+ if (!opening.result.objectStoreNames.contains(DOCUMENTS)) {
+ opening.result.createObjectStore(DOCUMENTS, { keyPath: ["storage", "key"] })
+ }
+ if (!opening.result.objectStoreNames.contains(BLOBS)) {
+ opening.result.createObjectStore(BLOBS, { keyPath: "digest" })
+ }
+ }
+ opening.onsuccess = () => resolve(opening.result)
+ opening.onerror = () => reject(opening.error)
+ })
+ return this.database
+ }
+
+ async read(address: DocumentAddress) {
+ const database = await this.open()
+ const record = (await request(
+ database.transaction(DOCUMENTS, "readonly").objectStore(DOCUMENTS).get(key(address)),
+ )) as DocumentRecord | undefined
+ return record?.value ?? null
+ }
+
+ commit(input: { address: DocumentAddress; value: string }) {
+ return this.track(
+ (async () => {
+ const database = await this.open()
+ const current = database.transaction(DOCUMENTS, "readwrite")
+ current.objectStore(DOCUMENTS).put({ ...input.address, value: input.value } satisfies DocumentRecord)
+ await transaction(current)
+ })(),
+ )
+ }
+
+ remove(address: DocumentAddress) {
+ return this.track(
+ (async () => {
+ const database = await this.open()
+ const current = database.transaction(DOCUMENTS, "readwrite")
+ current.objectStore(DOCUMENTS).delete(key(address))
+ await transaction(current)
+ })(),
+ )
+ }
+
+ putBlob(bytes: Uint8Array) {
+ return this.track(
+ (async () => {
+ const value = new Uint8Array(bytes)
+ const reference = {
+ digest: await sha256(value),
+ byteLength: value.byteLength,
+ }
+ const database = await this.open()
+ const current = database.transaction(BLOBS, "readwrite")
+ current.objectStore(BLOBS).put({ ...reference, bytes: value } satisfies BlobRecord)
+ await transaction(current)
+ return reference
+ })(),
+ )
+ }
+
+ async readBlob(reference: BlobReference) {
+ const database = await this.open()
+ const record = (await request(database.transaction(BLOBS, "readonly").objectStore(BLOBS).get(reference.digest))) as
+ | BlobRecord
+ | undefined
+ if (!record) return null
+ if (record.byteLength !== reference.byteLength || record.bytes.byteLength !== reference.byteLength) {
+ throw new Error(`Blob length mismatch: ${reference.digest}`)
+ }
+ if ((await sha256(record.bytes)) !== reference.digest) throw new Error(`Blob digest mismatch: ${reference.digest}`)
+ return new Uint8Array(record.bytes)
+ }
+
+ async drain() {
+ const writes = [...this.writes]
+ await Promise.all(writes)
+ }
+
+ private track(value: Promise) {
+ this.writes.add(value)
+ void value.then(
+ () => this.writes.delete(value),
+ () => this.writes.delete(value),
+ )
+ return value
+ }
+}
+
+let singleton: Repository | undefined
+
+export function getIndexedDBRepository() {
+ singleton ??= createRepository(new IndexedDBRepository())
+ return singleton
+}
diff --git a/packages/app/src/persistence/repository.test.ts b/packages/app/src/persistence/repository.test.ts
new file mode 100644
index 000000000000..b7ceca1a7f6f
--- /dev/null
+++ b/packages/app/src/persistence/repository.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, test, vi } from "bun:test"
+import { createRepository, type DurableRepository } from "./repository"
+
+describe("checkpoint repository", () => {
+ test("serializes only the latest value at the checkpoint deadline", async () => {
+ vi.useFakeTimers()
+ try {
+ const values: string[] = []
+ const durable: DurableRepository = {
+ read: async () => null,
+ commit: async (input) => {
+ values.push(input.value)
+ },
+ remove: async () => undefined,
+ putBlob: async (bytes) => ({ digest: "digest", byteLength: bytes.byteLength }),
+ readBlob: async () => null,
+ drain: async () => undefined,
+ }
+ const repository = createRepository(durable)
+ let serialized = 0
+ const address = { storage: "global", key: "prompt" }
+
+ repository.commit({ address, value: () => `${++serialized}:first` })
+ repository.commit({ address, value: () => `${++serialized}:latest` })
+ expect(serialized).toBe(0)
+
+ vi.advanceTimersByTime(500)
+ await Promise.resolve()
+ await Promise.resolve()
+ expect(values).toEqual(["1:latest"])
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ test("reads the latest pending value before its checkpoint", async () => {
+ let stored = "old"
+ const durable: DurableRepository = {
+ read: async () => stored,
+ commit: async (input) => {
+ stored = input.value
+ },
+ remove: async () => undefined,
+ putBlob: async (bytes) => ({ digest: "digest", byteLength: bytes.byteLength }),
+ readBlob: async () => null,
+ drain: async () => undefined,
+ }
+ const repository = createRepository(durable)
+ const address = { storage: "global", key: "prompt" }
+
+ repository.commit({ address, value: "new" })
+
+ expect(await repository.read(address)).toBe("new")
+ expect(stored).toBe("old")
+ await repository.drain()
+ })
+
+ test("does not recreate a document when removal overlaps a commit", async () => {
+ vi.useFakeTimers()
+ try {
+ const first = Promise.withResolvers()
+ let stored: string | null = null
+ let commits = 0
+ const durable: DurableRepository = {
+ read: async () => stored,
+ commit: async (input) => {
+ commits++
+ if (commits === 1) await first.promise
+ stored = input.value
+ },
+ remove: async () => {
+ stored = null
+ },
+ putBlob: async (bytes) => ({ digest: "digest", byteLength: bytes.byteLength }),
+ readBlob: async () => null,
+ drain: async () => undefined,
+ }
+ const repository = createRepository(durable)
+ const address = { storage: "global", key: "prompt" }
+
+ repository.commit({ address, value: "deleted" })
+ vi.advanceTimersByTime(500)
+ await Promise.resolve()
+ const removing = repository.remove(address)
+ first.resolve()
+ await removing
+ vi.advanceTimersByTime(500)
+ await Promise.resolve()
+
+ expect(await repository.read(address)).toBeNull()
+ expect(commits).toBe(1)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+})
diff --git a/packages/app/src/persistence/repository.ts b/packages/app/src/persistence/repository.ts
new file mode 100644
index 000000000000..8f18fe449480
--- /dev/null
+++ b/packages/app/src/persistence/repository.ts
@@ -0,0 +1,69 @@
+import { createCheckpointController } from "./checkpoint"
+
+export type DocumentAddress = {
+ storage: string
+ key: string
+}
+
+export type BlobReference = {
+ digest: string
+ byteLength: number
+}
+
+export type DocumentValue = string | (() => string)
+
+export interface Repository {
+ read(address: DocumentAddress): Promise
+ commit(input: { address: DocumentAddress; value: DocumentValue }): void
+ remove(address: DocumentAddress): Promise
+ putBlob(bytes: Uint8Array): Promise
+ readBlob(reference: BlobReference): Promise
+ drain(): Promise
+}
+
+export interface DurableRepository {
+ read(address: DocumentAddress): Promise
+ commit(input: { address: DocumentAddress; value: string }): Promise
+ remove(address: DocumentAddress): Promise
+ putBlob(bytes: Uint8Array): Promise
+ readBlob(reference: BlobReference): Promise
+ drain(): Promise
+}
+
+export function createRepository(durable: DurableRepository): Repository {
+ const checkpoints = new Map>>()
+ const id = (address: DocumentAddress) => `${address.storage}\0${address.key}`
+ const resolve = (value: DocumentValue) => (typeof value === "function" ? value() : value)
+
+ return {
+ async read(address) {
+ const pending = checkpoints.get(id(address))?.pending()
+ if (pending !== undefined) return resolve(pending)
+ return durable.read(address)
+ },
+ commit(input) {
+ const key = id(input.address)
+ const existing = checkpoints.get(key)
+ if (existing) return existing.checkpoint(input.value)
+ const checkpoint = createCheckpointController((value: DocumentValue) =>
+ durable.commit({ address: input.address, value: resolve(value) }),
+ )
+ checkpoints.set(key, checkpoint)
+ checkpoint.checkpoint(input.value)
+ },
+ async remove(address) {
+ const key = id(address)
+ const checkpoint = checkpoints.get(key)
+ checkpoint?.discard()
+ checkpoints.delete(key)
+ await checkpoint?.idle()
+ await durable.remove(address)
+ },
+ putBlob: (bytes) => durable.putBlob(bytes),
+ readBlob: (reference) => durable.readBlob(reference),
+ async drain() {
+ await Promise.all([...checkpoints.values()].map((checkpoint) => checkpoint.drain()))
+ await durable.drain()
+ },
+ }
+}
diff --git a/packages/app/src/utils/persist.test.ts b/packages/app/src/utils/persist.test.ts
index d8b822d856bb..d7ac59f062d4 100644
--- a/packages/app/src/utils/persist.test.ts
+++ b/packages/app/src/utils/persist.test.ts
@@ -1,211 +1,37 @@
-import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
+import { describe, expect, test } from "bun:test"
import { ServerScope } from "./server-scope"
+import { Persist, PersistTesting } from "./persist"
-type PersistTestingType = typeof import("./persist").PersistTesting
-type PersistType = typeof import("./persist").Persist
-type RemovePersistedType = typeof import("./persist").removePersisted
-
-class MemoryStorage implements Storage {
- private values = new Map()
- readonly events: string[] = []
- readonly calls = { get: 0, set: 0, remove: 0 }
-
- clear() {
- this.values.clear()
- }
-
- get length() {
- return this.values.size
- }
-
- key(index: number) {
- return Array.from(this.values.keys())[index] ?? null
- }
-
- getItem(key: string) {
- this.calls.get += 1
- this.events.push(`get:${key}`)
- if (key.startsWith("opencode.throw")) throw new Error("storage get failed")
- return this.values.get(key) ?? null
- }
-
- setItem(key: string, value: string) {
- this.calls.set += 1
- this.events.push(`set:${key}`)
- if (key.startsWith("opencode.quota")) throw new DOMException("quota", "QuotaExceededError")
- if (key.startsWith("opencode.throw")) throw new Error("storage set failed")
- this.values.set(key, value)
- }
-
- removeItem(key: string) {
- this.calls.remove += 1
- this.events.push(`remove:${key}`)
- if (key.startsWith("opencode.throw")) throw new Error("storage remove failed")
- this.values.delete(key)
- }
-}
-
-const storage = new MemoryStorage()
-
-let persistTesting: PersistTestingType
-let Persist: PersistType
-let removePersisted: RemovePersistedType
-
-beforeAll(async () => {
- mock.module("@/context/platform", () => ({
- usePlatform: () => ({ platform: "web" }),
- }))
-
- const mod = await import("./persist")
- persistTesting = mod.PersistTesting
- Persist = mod.Persist
- removePersisted = mod.removePersisted
-})
-
-beforeEach(() => {
- storage.clear()
- storage.events.length = 0
- storage.calls.get = 0
- storage.calls.set = 0
- storage.calls.remove = 0
- Object.defineProperty(globalThis, "localStorage", {
- value: storage,
- configurable: true,
- })
-})
-
-describe("persist localStorage resilience", () => {
- test("does not cache values as persisted when quota write and eviction fail", () => {
- const storageApi = persistTesting.localStorageWithPrefix("opencode.quota.scope")
- storageApi.setItem("value", '{"value":1}')
-
- expect(storage.getItem("opencode.quota.scope:value")).toBeNull()
- expect(storageApi.getItem("value")).toBeNull()
- })
-
- test("disables only the failing scope when storage throws", () => {
- const bad = persistTesting.localStorageWithPrefix("opencode.throw.scope")
- bad.setItem("value", '{"value":1}')
-
- const before = storage.calls.set
- bad.setItem("value", '{"value":2}')
- expect(storage.calls.set).toBe(before)
- expect(bad.getItem("value")).toBeNull()
-
- const healthy = persistTesting.localStorageWithPrefix("opencode.safe.scope")
- healthy.setItem("value", '{"value":3}')
- expect(storage.getItem("opencode.safe.scope:value")).toBe('{"value":3}')
- })
-
- test("failing fallback scope does not poison direct storage scope", () => {
- const broken = persistTesting.localStorageWithPrefix("opencode.throw.scope2")
- broken.setItem("value", '{"value":1}')
-
- const direct = persistTesting.localStorageDirect()
- direct.setItem("direct-value", '{"value":5}')
-
- expect(storage.getItem("direct-value")).toBe('{"value":5}')
- })
-
+describe("Persist addresses", () => {
test("normalizer rejects malformed JSON payloads", () => {
- const result = persistTesting.normalize({ value: "ok" }, '{"value":"\\x"}')
- expect(result).toBeUndefined()
+ expect(PersistTesting.normalize({ value: "ok" }, '{"value":"\\x"}')).toBeUndefined()
})
test("workspace storage sanitizes Windows filename characters", () => {
- const result = persistTesting.workspaceStorage("C:\\Users\\foo")
-
+ const result = PersistTesting.workspaceStorage("C:\\Users\\foo")
expect(result).toStartWith("opencode.workspace.")
expect(result.endsWith(".dat")).toBeTrue()
expect(/[:\\/]/.test(result)).toBeFalse()
})
- test("workspace target keeps raw path storage as legacy fallback", () => {
+ test("workspace target keeps path variants as legacy addresses", () => {
const target = Persist.workspace("C:\\Users\\foo", "vcs")
-
- expect(target.storage).toBe(persistTesting.workspaceStorage("C:/Users/foo"))
- expect(target.legacyStorageNames).toEqual([persistTesting.workspaceStorage("C:\\Users\\foo")])
- })
-
- test("workspace target keeps backslash storage as fallback for normalized Windows paths", () => {
- const target = Persist.workspace("C:/Users/foo", "vcs")
-
- expect(target.storage).toBe(persistTesting.workspaceStorage("C:/Users/foo"))
- expect(target.legacyStorageNames).toEqual([persistTesting.workspaceStorage("C:\\Users\\foo")])
+ expect(target.storage).toBe(PersistTesting.workspaceStorage("C:/Users/foo"))
+ expect(target.legacyStorageNames).toEqual([PersistTesting.workspaceStorage("C:\\Users\\foo")])
})
- test("migrates direct legacy keys into scoped storage", () => {
- storage.setItem("legacy.workspace", '{"value":2}')
- const target = Persist.workspace("C:/Users/foo", "demo", ["legacy.workspace"])
- const current = persistTesting.localStorageWithPrefix(target.storage!)
- const legacyStore = persistTesting.localStorageDirect()
-
- const result = persistTesting.migrateLegacy({
- current,
- legacyStore,
- stores: [],
- keys: target.legacy!,
- key: target.key,
- defaults: { value: 1 },
- })
-
- expect(result).toBe('{"value":2}')
- expect(storage.getItem(`${target.storage}:${target.key}`)).toBe('{"value":2}')
- expect(legacyStore.getItem("legacy.workspace")).toBeNull()
- expect(storage.getItem("legacy.workspace")).toBeNull()
- })
-
- test("removes legacy workspace storage when removing persisted target", () => {
- const target = Persist.workspace("C:\\Users\\foo", "terminal")
- storage.setItem(`${target.storage}:${target.key}`, '{"value":1}')
- storage.setItem(`${target.legacyStorageNames![0]}:${target.key}`, '{"value":2}')
-
- removePersisted(target)
-
- expect(storage.getItem(`${target.storage}:${target.key}`)).toBeNull()
- expect(storage.getItem(`${target.legacyStorageNames![0]}:${target.key}`)).toBeNull()
+ test("draft target isolates storage and namespaces keys", () => {
+ const first = Persist.draft("draft-a", "prompt")
+ const second = Persist.draft("draft-b", "prompt")
+ expect(first.key).toBe("draft:prompt")
+ expect(first.storage).not.toBe(second.storage)
})
- test("draft target isolates storage per draft and namespaces keys", () => {
- const a = Persist.draft("draft-a", "prompt")
- const b = Persist.draft("draft-b", "prompt")
-
- expect(a.key).toBe("draft:prompt")
- expect(a.storage).not.toBe(b.storage)
- expect(a.storage).not.toBe(Persist.workspace("/home/luke/repo", "prompt").storage)
- })
-
- test("removes draft storage when removing persisted target", () => {
- const target = Persist.draft("draft-a", "prompt")
- storage.setItem(`${target.storage}:${target.key}`, '{"value":1}')
-
- removePersisted(target)
-
- expect(storage.getItem(`${target.storage}:${target.key}`)).toBeNull()
- })
-
- test("server workspace target preserves local storage and isolates remote storage", () => {
+ test("server targets preserve local addresses and isolate remote scopes", () => {
const local = Persist.serverWorkspace(ServerScope.local, "/home/luke/repo", "prompt")
- const windows = Persist.serverWorkspace("https://windows.example" as ServerScope, "/home/luke/repo", "prompt")
- const debian = Persist.serverWorkspace("https://debian.example" as ServerScope, "/home/luke/repo", "prompt")
-
+ const remote = Persist.serverWorkspace("https://debian.example" as ServerScope, "/home/luke/repo", "prompt")
expect(local).toEqual(Persist.workspace("/home/luke/repo", "prompt"))
- expect(windows.storage).not.toBe(local.storage)
- expect(debian.storage).not.toBe(local.storage)
- expect(debian.storage).not.toBe(windows.storage)
- expect(windows.legacyStorageNames).toBeUndefined()
- expect(debian.legacyStorageNames).toBeUndefined()
- })
-
- test("server global target preserves local key and isolates remote keys", () => {
- expect(Persist.serverGlobal(ServerScope.local, "notification")).toEqual(Persist.global("notification"))
- expect(Persist.serverGlobal("https://debian.example" as ServerScope, "notification")).toEqual({
- storage: "opencode.global.dat",
- key: "https://debian.example\0notification",
- })
- })
-
- test("server global target cannot collide when scope and key contain colons", () => {
- expect(Persist.serverGlobal("a:b" as ServerScope, "c")).not.toEqual(Persist.serverGlobal("a" as ServerScope, "b:c"))
+ expect(remote.storage).not.toBe(local.storage)
+ expect(remote.legacyStorageNames).toBeUndefined()
})
})
diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts
index a2daae4866bc..7006ed80a693 100644
--- a/packages/app/src/utils/persist.ts
+++ b/packages/app/src/utils/persist.ts
@@ -1,8 +1,8 @@
import { Platform, usePlatform } from "@/context/platform"
-import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
+import { getIndexedDBRepository, type DocumentAddress, type Repository } from "@/persistence"
import { checksum } from "@opencode-ai/core/util/encode"
import { createResource, type Accessor } from "solid-js"
-import type { SetStoreFunction, Store } from "solid-js/store"
+import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import { pathKey } from "@/utils/path-key"
import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope"
@@ -11,7 +11,7 @@ type PersistedWithReady = [
Store,
SetStoreFunction,
InitType,
- Accessor & { promise: undefined | Promise },
+ Accessor & { promise: undefined | Promise },
]
type PersistTarget = {
@@ -23,145 +23,14 @@ type PersistTarget = {
migrate?: (value: unknown) => unknown
}
+type LegacyValue = {
+ value: string
+ remove(): void
+}
+
const LEGACY_STORAGE = "default.dat"
const GLOBAL_STORAGE = "opencode.global.dat"
const WINDOW_STORAGE = "opencode.window"
-const LOCAL_PREFIX = "opencode."
-const fallback = new Map()
-
-const CACHE_MAX_ENTRIES = 500
-const CACHE_MAX_BYTES = 8 * 1024 * 1024
-
-type CacheEntry = { value: string; bytes: number }
-const cache = new Map()
-const cacheTotal = { bytes: 0 }
-
-function cacheDelete(key: string) {
- const entry = cache.get(key)
- if (!entry) return
- cacheTotal.bytes -= entry.bytes
- cache.delete(key)
-}
-
-function cachePrune() {
- for (;;) {
- if (cache.size <= CACHE_MAX_ENTRIES && cacheTotal.bytes <= CACHE_MAX_BYTES) return
- const oldest = cache.keys().next().value as string | undefined
- if (!oldest) return
- cacheDelete(oldest)
- }
-}
-
-function cacheSet(key: string, value: string) {
- const bytes = value.length * 2
- if (bytes > CACHE_MAX_BYTES) {
- cacheDelete(key)
- return
- }
-
- const entry = cache.get(key)
- if (entry) cacheTotal.bytes -= entry.bytes
- cache.delete(key)
- cache.set(key, { value, bytes })
- cacheTotal.bytes += bytes
- cachePrune()
-}
-
-function cacheGet(key: string) {
- const entry = cache.get(key)
- if (!entry) return
- cache.delete(key)
- cache.set(key, entry)
- return entry.value
-}
-
-function fallbackDisabled(scope: string) {
- return fallback.get(scope) === true
-}
-
-function fallbackSet(scope: string) {
- fallback.set(scope, true)
-}
-
-function quota(error: unknown) {
- if (error instanceof DOMException) {
- if (error.name === "QuotaExceededError") return true
- if (error.name === "NS_ERROR_DOM_QUOTA_REACHED") return true
- if (error.name === "QUOTA_EXCEEDED_ERR") return true
- if (error.code === 22 || error.code === 1014) return true
- return false
- }
-
- if (!error || typeof error !== "object") return false
- const name = (error as { name?: string }).name
- if (name === "QuotaExceededError" || name === "NS_ERROR_DOM_QUOTA_REACHED") return true
- if (name && /quota/i.test(name)) return true
-
- const code = (error as { code?: number }).code
- if (code === 22 || code === 1014) return true
-
- const message = (error as { message?: string }).message
- if (typeof message !== "string") return false
- if (/quota/i.test(message)) return true
- return false
-}
-
-type Evict = { key: string; size: number }
-
-function evict(storage: Storage, keep: string, value: string) {
- const total = storage.length
- const indexes = Array.from({ length: total }, (_, index) => index)
- const items: Evict[] = []
-
- for (const index of indexes) {
- const name = storage.key(index)
- if (!name) continue
- if (!name.startsWith(LOCAL_PREFIX)) continue
- if (name === keep) continue
- const stored = storage.getItem(name)
- items.push({ key: name, size: stored?.length ?? 0 })
- }
-
- items.sort((a, b) => b.size - a.size)
-
- for (const item of items) {
- storage.removeItem(item.key)
- cacheDelete(item.key)
-
- try {
- storage.setItem(keep, value)
- cacheSet(keep, value)
- return true
- } catch (error) {
- if (!quota(error)) throw error
- }
- }
-
- return false
-}
-
-function write(storage: Storage, key: string, value: string) {
- try {
- storage.setItem(key, value)
- cacheSet(key, value)
- return true
- } catch (error) {
- if (!quota(error)) throw error
- }
-
- try {
- storage.removeItem(key)
- cacheDelete(key)
- storage.setItem(key, value)
- cacheSet(key, value)
- return true
- } catch (error) {
- if (!quota(error)) throw error
- }
-
- const ok = evict(storage, key, value)
- return ok
-}
function snapshot(value: unknown) {
return JSON.parse(JSON.stringify(value)) as unknown
@@ -174,27 +43,15 @@ function isRecord(value: unknown): value is Record {
function merge(defaults: unknown, value: unknown): unknown {
if (value === undefined) return defaults
if (value === null) return value
+ if (Array.isArray(defaults)) return Array.isArray(value) ? value : defaults
+ if (!isRecord(defaults)) return value
+ if (!isRecord(value)) return defaults
- if (Array.isArray(defaults)) {
- if (Array.isArray(value)) return value
- return defaults
- }
-
- if (isRecord(defaults)) {
- if (!isRecord(value)) return defaults
-
- const result: Record = { ...defaults }
- for (const key of Object.keys(value)) {
- if (key in defaults) {
- result[key] = merge((defaults as Record)[key], (value as Record)[key])
- } else {
- result[key] = (value as Record)[key]
- }
- }
- return result
- }
-
- return value
+ const result: Record = { ...defaults }
+ Object.keys(value).forEach((key) => {
+ result[key] = key in defaults ? merge(defaults[key], value[key]) : value[key]
+ })
+ return result
}
function parse(value: string) {
@@ -208,133 +65,31 @@ function parse(value: string) {
function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) => unknown) {
const parsed = parse(raw)
if (parsed === undefined) return
- const migrated = migrate ? migrate(parsed) : parsed
- const merged = merge(defaults, migrated)
- return JSON.stringify(merged)
-}
-
-function readCurrent(input: {
- storage: SyncStorage
- key: string
- defaults: unknown
- migrate?: (value: unknown) => unknown
-}) {
- const raw = input.storage.getItem(input.key)
- if (raw === null) return
- const next = normalize(input.defaults, raw, input.migrate)
- if (next === undefined) {
- input.storage.removeItem(input.key)
- return null
- }
- if (raw !== next) input.storage.setItem(input.key, next)
- return next
-}
-
-function migrateLegacy(input: {
- current: SyncStorage
- legacyStore?: SyncStorage
- stores: SyncStorage[]
- keys: string[]
- key: string
- defaults: unknown
- migrate?: (value: unknown) => unknown
-}) {
- for (const store of input.stores) {
- const raw = store.getItem(input.key)
- if (raw === null) continue
-
- const next = normalize(input.defaults, raw, input.migrate)
- if (next === undefined) {
- store.removeItem(input.key)
- continue
+ return JSON.stringify(merge(defaults, migrate ? migrate(parsed) : parsed))
+}
+
+async function migrateAttachmentData(persistence: Repository, raw: string) {
+ if (!raw.includes('"dataUrl"')) return raw
+ const visit = async (value: unknown): Promise => {
+ if (Array.isArray(value)) return Promise.all(value.map(visit))
+ if (!isRecord(value)) return value
+ if (value.type === "image" && typeof value.dataUrl === "string") {
+ const match = /^data:[^;,]+;base64,(.*)$/s.exec(value.dataUrl)
+ if (!match) return value
+ const decoded = atob(match[1])
+ const bytes = Uint8Array.from(decoded, (char) => char.charCodeAt(0))
+ const blob = await persistence.putBlob(bytes)
+ return Object.fromEntries(
+ Object.entries(value)
+ .filter(([key]) => key !== "dataUrl")
+ .concat([["blob", blob]]),
+ )
}
- input.current.setItem(input.key, next)
- store.removeItem(input.key)
- return next
+ return Object.fromEntries(
+ await Promise.all(Object.entries(value).map(async ([key, item]) => [key, await visit(item)])),
+ )
}
-
- if (!input.legacyStore) return null
-
- for (const key of input.keys) {
- const raw = input.legacyStore.getItem(key)
- if (raw === null) continue
-
- const next = normalize(input.defaults, raw, input.migrate)
- if (next === undefined) {
- input.legacyStore.removeItem(key)
- continue
- }
- input.current.setItem(input.key, next)
- input.legacyStore.removeItem(key)
- return next
- }
-
- return null
-}
-
-async function readCurrentAsync(input: {
- storage: AsyncStorage
- key: string
- defaults: unknown
- migrate?: (value: unknown) => unknown
-}) {
- const raw = await input.storage.getItem(input.key)
- if (raw === null) return
- const next = normalize(input.defaults, raw, input.migrate)
- if (next === undefined) {
- await input.storage.removeItem(input.key).catch(() => undefined)
- return null
- }
- if (raw !== next) await input.storage.setItem(input.key, next)
- return next
-}
-
-async function removeAsync(storage: AsyncStorage, key: string) {
- try {
- await storage.removeItem(key)
- } catch {}
-}
-
-async function migrateLegacyAsync(input: {
- current: AsyncStorage
- legacyStore?: AsyncStorage
- stores: AsyncStorage[]
- keys: string[]
- key: string
- defaults: unknown
- migrate?: (value: unknown) => unknown
-}) {
- for (const store of input.stores) {
- const raw = await store.getItem(input.key)
- if (raw === null) continue
-
- const next = normalize(input.defaults, raw, input.migrate)
- if (next === undefined) {
- await removeAsync(store, input.key)
- continue
- }
- await input.current.setItem(input.key, next)
- await store.removeItem(input.key)
- return next
- }
-
- if (!input.legacyStore) return null
-
- for (const key of input.keys) {
- const raw = await input.legacyStore.getItem(key)
- if (raw === null) continue
-
- const next = normalize(input.defaults, raw, input.migrate)
- if (next === undefined) {
- await removeAsync(input.legacyStore, key)
- continue
- }
- await input.current.setItem(input.key, next)
- await input.legacyStore.removeItem(key)
- return next
- }
-
- return null
+ return JSON.stringify(await visit(parse(raw)))
}
function workspaceStorage(dir: string) {
@@ -361,14 +116,11 @@ function legacyWorkspaceStorage(dir: string) {
if (raw !== storage) result.add(raw)
const key = pathKey(dir)
- const drive = key.length >= 3 && key[1] === ":" && key[2] === "/"
- if (drive) {
+ if (key.length >= 3 && key[1] === ":" && key[2] === "/") {
const backslash = workspaceStorage(key.replaceAll("/", "\\"))
if (backslash !== storage) result.add(backslash)
}
-
- if (result.size === 0) return
- return [...result]
+ return result.size ? [...result] : undefined
}
function serverWorkspaceTarget(scope: ServerScopeValue, dir: string, key: string, legacy?: string[]): PersistTarget {
@@ -376,91 +128,99 @@ function serverWorkspaceTarget(scope: ServerScopeValue, dir: string, key: string
return { storage: workspaceStorage(pathKey(dir)), legacyStorageNames: legacyWorkspaceStorage(dir), key, legacy }
}
-function localStorageWithPrefix(prefix: string): SyncStorage {
- const base = `${prefix}:`
- const scope = `prefix:${prefix}`
- const item = (key: string) => base + key
+function resolveTarget(target: PersistTarget, platform: Platform): PersistTarget {
+ if (target.scope !== "window") return target
+ if (platform.platform === "desktop" && !platform.windowID) return { ...target, storage: GLOBAL_STORAGE }
return {
- getItem: (key) => {
- const name = item(key)
- const cached = cacheGet(name)
- if (fallbackDisabled(scope)) return cached ?? null
+ ...target,
+ storage: windowStorage(platform.platform === "desktop" ? (platform.windowID ?? "browser") : "browser"),
+ }
+}
+
+function address(target: PersistTarget): DocumentAddress {
+ return { storage: target.storage ?? LEGACY_STORAGE, key: target.key }
+}
- const stored = (() => {
+function repository(platform: Platform) {
+ return platform.persistence ?? getIndexedDBRepository()
+}
+
+function legacyItem(storage: string | undefined, key: string): LegacyValue | undefined {
+ const name = storage ? `${storage}:${key}` : key
+ try {
+ const value = localStorage.getItem(name)
+ if (value === null) return
+ return {
+ value,
+ remove: () => {
try {
- return localStorage.getItem(name)
- } catch {
- fallbackSet(scope)
- return null
- }
- })()
- if (stored === null) return cached ?? null
- cacheSet(name, stored)
- return stored
- },
- setItem: (key, value) => {
- const name = item(key)
- if (fallbackDisabled(scope)) return
- try {
- if (write(localStorage, name, value)) return
- } catch {
- fallbackSet(scope)
- return
- }
- fallbackSet(scope)
- },
- removeItem: (key) => {
- const name = item(key)
- cacheDelete(name)
- if (fallbackDisabled(scope)) return
- try {
- localStorage.removeItem(name)
- } catch {
- fallbackSet(scope)
- }
- },
+ localStorage.removeItem(name)
+ } catch {}
+ },
+ }
+ } catch {
+ return
}
}
-function localStorageDirect(): SyncStorage {
- const scope = "direct"
- return {
- getItem: (key) => {
- const cached = cacheGet(key)
- if (fallbackDisabled(scope)) return cached ?? null
+function removeLegacy(storage: string | undefined, key: string) {
+ legacyItem(storage, key)?.remove()
+}
- const stored = (() => {
- try {
- return localStorage.getItem(key)
- } catch {
- fallbackSet(scope)
- return null
- }
- })()
- if (stored === null) return cached ?? null
- cacheSet(key, stored)
- return stored
- },
- setItem: (key, value) => {
- if (fallbackDisabled(scope)) return
- try {
- if (write(localStorage, key, value)) return
- } catch {
- fallbackSet(scope)
- return
- }
- fallbackSet(scope)
- },
- removeItem: (key) => {
- cacheDelete(key)
- if (fallbackDisabled(scope)) return
- try {
- localStorage.removeItem(key)
- } catch {
- fallbackSet(scope)
- }
- },
+function readLegacy(target: PersistTarget, defaults: unknown) {
+ const current = legacyItem(target.storage, target.key)
+ if (current) {
+ const value = normalize(defaults, current.value, target.migrate)
+ if (value === undefined) {
+ current.remove()
+ return null
+ }
+ return { value, source: current }
+ }
+
+ for (const storage of target.legacyStorageNames ?? []) {
+ const source = legacyItem(storage, target.key)
+ if (!source) continue
+ const value = normalize(defaults, source.value, target.migrate)
+ if (value === undefined) {
+ source.remove()
+ continue
+ }
+ return { value, source }
+ }
+
+ for (const key of target.legacy ?? []) {
+ const source = legacyItem(undefined, key)
+ if (!source) continue
+ const value = normalize(defaults, source.value, target.migrate)
+ if (value === undefined) {
+ source.remove()
+ continue
+ }
+ return { value, source }
}
+ return null
+}
+
+async function readRepositoryLegacy(persistence: Repository, target: PersistTarget, defaults: unknown) {
+ const candidates = [
+ ...(target.legacyStorageNames ?? []).map((storage) => ({ storage, key: target.key })),
+ ...(target.legacy ?? []).map((key) => ({ storage: LEGACY_STORAGE, key })),
+ ]
+ for (const candidate of candidates) {
+ const raw = await persistence.read(candidate)
+ if (raw === null) continue
+ const value = normalize(defaults, raw, target.migrate)
+ if (value === undefined) {
+ await persistence.remove(candidate)
+ continue
+ }
+ return {
+ value,
+ source: { remove: () => persistence.remove(candidate) },
+ }
+ }
+ return null
}
const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "file-view", "layout"]
@@ -470,9 +230,6 @@ export function draftPersistedKeys() {
}
export const PersistTesting = {
- localStorageDirect,
- localStorageWithPrefix,
- migrateLegacy,
normalize,
resolveTarget,
windowStorage,
@@ -515,39 +272,20 @@ export const Persist = {
},
}
-function resolveTarget(target: PersistTarget, platform: Platform): PersistTarget {
- if (target.scope !== "window") return target
- if (platform.platform === "desktop" && !platform.windowID) return { ...target, storage: GLOBAL_STORAGE }
- const windowID = platform.platform === "desktop" ? (platform.windowID ?? "browser") : "browser"
- return {
- ...target,
- storage: windowStorage(windowID),
- }
-}
-
export function removePersisted(
- target: { storage?: string; legacyStorageNames?: string[]; key: string },
+ target: { storage?: string; scope?: "window"; legacyStorageNames?: string[]; key: string; legacy?: string[] },
platform?: Platform,
) {
- const isDesktop = platform?.platform === "desktop" && !!platform.storage
-
- if (isDesktop) {
- void platform.storage?.(target.storage)?.removeItem(target.key)
- for (const storage of target.legacyStorageNames ?? []) {
- void platform.storage?.(storage)?.removeItem(target.key)
- }
- return
- }
-
- if (!target.storage) {
- localStorageDirect().removeItem(target.key)
- return
- }
-
- localStorageWithPrefix(target.storage).removeItem(target.key)
- for (const storage of target.legacyStorageNames ?? []) {
- localStorageWithPrefix(storage).removeItem(target.key)
- }
+ const config = platform ? resolveTarget(target, platform) : target
+ removeLegacy(config.storage, config.key)
+ config.legacyStorageNames?.forEach((storage) => removeLegacy(storage, config.key))
+ config.legacy?.forEach((key) => removeLegacy(undefined, key))
+ const persistence = platform?.persistence ?? getIndexedDBRepository()
+ return Promise.all([
+ persistence.remove(address(config)),
+ ...(config.legacyStorageNames ?? []).map((storage) => persistence.remove({ storage, key: config.key })),
+ ...(config.legacy ?? []).map((key) => persistence.remove({ storage: LEGACY_STORAGE, key })),
+ ]).then(() => undefined)
}
export function persisted(
@@ -556,106 +294,50 @@ export function persisted(
): PersistedWithReady {
const platform = usePlatform()
const config = resolveTarget(typeof target === "string" ? { key: target } : target, platform)
-
+ const persistence = repository(platform)
+ const document = address(config)
const defaults = snapshot(store[0])
- const legacy = config.legacy ?? []
-
- const isDesktop = platform.platform === "desktop" && !!platform.storage
-
- const currentStorage = (() => {
- if (isDesktop) return platform.storage?.(config.storage)
- if (!config.storage) return localStorageDirect()
- return localStorageWithPrefix(config.storage)
- })()
-
- const legacyStorage = (() => {
- if (!isDesktop) return localStorageDirect()
- if (!config.storage) return platform.storage?.()
- return platform.storage?.(LEGACY_STORAGE)
- })()
-
- const legacyStorageNames = config.legacyStorageNames ?? []
-
- const storage = (() => {
- if (!isDesktop) {
- const current = currentStorage as SyncStorage
- const legacyStore = legacyStorage as SyncStorage
- const legacyStores = legacyStorageNames.map(localStorageWithPrefix)
-
- const api: SyncStorage = {
- getItem: (key) => {
- const value = readCurrent({ storage: current, key, defaults, migrate: config.migrate })
- if (value !== undefined) return value
- return migrateLegacy({
- current,
- legacyStore,
- stores: legacyStores,
- keys: legacy,
- key,
- defaults,
- migrate: config.migrate,
- })
- },
- setItem: (key, value) => {
- current.setItem(key, value)
- },
- removeItem: (key) => {
- current.removeItem(key)
- },
+ let mutations = 0
+
+ const init = (async () => {
+ const current = await persistence.read(document)
+ if (mutations) return current ?? ""
+ if (current !== null) {
+ const normalized = normalize(defaults, current, config.migrate)
+ if (normalized === undefined) {
+ await persistence.remove(document)
+ return ""
}
-
- return api
+ const value = await migrateAttachmentData(persistence, normalized)
+ if (!mutations) store[1](reconcile(parse(value) as T))
+ if (current !== value) persistence.commit({ address: document, value })
+ return value
}
- const current = currentStorage as AsyncStorage
- const legacyStore = legacyStorage as AsyncStorage | undefined
- const legacyStores = legacyStorageNames
- .map((name) => platform.storage?.(name) as AsyncStorage | undefined)
- .filter((x) => !!x)
-
- const api: AsyncStorage = {
- getItem: async (key) => {
- const value = await readCurrentAsync({ storage: current, key, defaults, migrate: config.migrate })
- if (value !== undefined) return value
- return migrateLegacyAsync({
- current,
- legacyStore,
- stores: legacyStores,
- keys: legacy,
- key,
- defaults,
- migrate: config.migrate,
- })
- },
- setItem: async (key, value) => {
- await current.setItem(key, value)
- },
- removeItem: async (key) => {
- await current.removeItem(key)
- },
- }
-
- return api
+ const legacy = (await readRepositoryLegacy(persistence, config, defaults)) ?? readLegacy(config, defaults)
+ if (!legacy || mutations) return ""
+ const value = await migrateAttachmentData(persistence, legacy.value)
+ persistence.commit({ address: document, value })
+ await persistence.drain()
+ await legacy.source.remove()
+ if (!mutations) store[1](reconcile(parse(value) as T))
+ return value
})()
- const [state, setState, init] = makePersisted(store, { name: config.key, storage })
+ const setState = ((...args: unknown[]) => {
+ Reflect.apply(store[1] as unknown as (...values: unknown[]) => void, undefined, args)
+ mutations += 1
+ persistence.commit({ address: document, value: () => JSON.stringify(snapshot(store[0])) })
+ }) as unknown as SetStoreFunction
- const isAsync = init instanceof Promise
const [ready] = createResource(
() => init,
- async (initValue) => {
- if (initValue instanceof Promise) await initValue
+ async (value: Promise) => {
+ await value
return true
},
- { initialValue: !isAsync },
+ { initialValue: false },
)
- return [
- state,
- setState,
- init,
- Object.assign(() => (ready.loading ? false : ready.latest === true), {
- promise: init instanceof Promise ? init : undefined,
- }),
- ]
+ return [store[0], setState, init, Object.assign(() => !ready.loading && ready.latest === true, { promise: init })]
}
diff --git a/packages/app/src/utils/prompt.test.ts b/packages/app/src/utils/prompt.test.ts
index 1ecaf02c978e..70c32a71c85b 100644
--- a/packages/app/src/utils/prompt.test.ts
+++ b/packages/app/src/utils/prompt.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Part } from "@opencode-ai/sdk/v2"
-import { extractPromptFromParts } from "./prompt"
+import { extractPromptFromParts, rememberAttachmentDataUrl, restorePromptFromParts } from "./prompt"
describe("extractPromptFromParts", () => {
test("restores multiple uploaded attachments", () => {
@@ -32,13 +32,54 @@ describe("extractPromptFromParts", () => {
},
] satisfies Part[]
+ rememberAttachmentDataUrl("data:image/png;base64,AAA", { digest: "a", byteLength: 3 })
+ rememberAttachmentDataUrl("data:application/pdf;base64,BBB", { digest: "b", byteLength: 3 })
const result = extractPromptFromParts(parts)
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: { digest: "a", byteLength: 3 } },
+ { type: "image", filename: "b.pdf", mime: "application/pdf", blob: { digest: "b", byteLength: 3 } },
+ ])
+ })
+
+ test("stores historical data URLs when the process has no attachment mapping", async () => {
+ const parts = [
+ {
+ id: "file_restart",
+ type: "file",
+ mime: "image/png",
+ url: "data:image/png;base64,AP8Q",
+ filename: "restart.png",
+ sessionID: "ses_1",
+ messageID: "msg_1",
+ },
+ ] satisfies Part[]
+ const stored: Uint8Array[] = []
+
+ const result = await restorePromptFromParts(parts, {
+ putBlob: async (bytes) => {
+ stored.push(bytes)
+ return { digest: "restored", byteLength: bytes.byteLength }
+ },
+ })
+
+ expect(stored).toEqual([new Uint8Array([0, 255, 16])])
+ expect(result).toEqual([
+ {
+ type: "text",
+ content: "",
+ start: 0,
+ end: 0,
+ },
+ {
+ type: "image",
+ id: "file_restart",
+ filename: "restart.png",
+ mime: "image/png",
+ blob: { digest: "restored", byteLength: 3 },
+ },
])
})
})
diff --git a/packages/app/src/utils/prompt.ts b/packages/app/src/utils/prompt.ts
index 35aec0071aac..324ef495e616 100644
--- a/packages/app/src/utils/prompt.ts
+++ b/packages/app/src/utils/prompt.ts
@@ -1,5 +1,33 @@
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@opencode-ai/sdk/v2"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
+import type { BlobReference } from "@/persistence"
+import { checksum } from "@opencode-ai/core/util/encode"
+
+const attachmentDataUrls = new Map()
+
+export function attachmentReferenceUrl(reference: BlobReference) {
+ return `opencode-blob:${encodeURIComponent(reference.digest)}?byteLength=${reference.byteLength}`
+}
+
+export function rememberAttachmentDataUrl(url: string, reference: BlobReference) {
+ const key = checksum(url)
+ if (!key) return
+ attachmentDataUrls.delete(key)
+ attachmentDataUrls.set(key, reference)
+ const oldest = attachmentDataUrls.keys().next().value
+ if (attachmentDataUrls.size > 100 && oldest) attachmentDataUrls.delete(oldest)
+}
+
+function attachmentReferenceFromUrl(url: string) {
+ if (!url.startsWith("opencode-blob:")) {
+ const key = checksum(url)
+ return key ? attachmentDataUrls.get(key) : undefined
+ }
+ const value = new URL(url)
+ const byteLength = Number(value.searchParams.get("byteLength"))
+ if (!Number.isSafeInteger(byteLength) || byteLength < 0) return
+ return { digest: decodeURIComponent(value.pathname), byteLength }
+}
type Inline =
| {
@@ -101,13 +129,14 @@ export function extractPromptFromParts(parts: Part[], opts?: { directory?: strin
continue
}
- if (filePart.url.startsWith("data:")) {
+ const blob = attachmentReferenceFromUrl(filePart.url)
+ if (blob) {
images.push({
type: "image",
id: filePart.id,
filename: filePart.filename ?? attachmentName,
mime: filePart.mime,
- dataUrl: filePart.url,
+ blob,
})
}
}
@@ -201,3 +230,35 @@ export function extractPromptFromParts(parts: Part[], opts?: { directory?: strin
if (images.length === 0) return result
return [...result, ...images]
}
+
+export async function restorePromptFromParts(
+ parts: Part[],
+ input: {
+ directory?: string
+ attachmentName?: string
+ putBlob: (bytes: Uint8Array) => Promise
+ },
+) {
+ const prompt = extractPromptFromParts(parts, input)
+ const restored = new Set(prompt.flatMap((part) => (part.type === "image" ? [part.id] : [])))
+ const images = await Promise.all(
+ parts.flatMap((part) => {
+ if (part.type !== "file" || part.source?.text || restored.has(part.id) || !part.url.startsWith("data:")) return []
+ return [
+ (async () => {
+ const comma = part.url.indexOf(",")
+ const decoded = atob(part.url.slice(comma + 1))
+ const bytes = Uint8Array.from(decoded, (char) => char.charCodeAt(0))
+ return {
+ type: "image" as const,
+ id: part.id,
+ filename: part.filename ?? input.attachmentName ?? "attachment",
+ mime: part.mime,
+ blob: await input.putBlob(bytes),
+ }
+ })(),
+ ]
+ }),
+ )
+ return images.length > 0 ? [...prompt, ...images] : prompt
+}
diff --git a/packages/app/test-browser/prompt-attachments.test.ts b/packages/app/test-browser/prompt-attachments.test.ts
index 49dad279cdf2..468231fbeffa 100644
--- a/packages/app/test-browser/prompt-attachments.test.ts
+++ b/packages/app/test-browser/prompt-attachments.test.ts
@@ -1,12 +1,111 @@
import { describe, expect, test } from "bun:test"
-import { createRoot } from "solid-js"
+import { createEffect, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { createPromptAttachmentsCore } from "@/components/prompt-input/attachments"
-import { createPromptState } from "@/context/prompt"
+import { createPromptState, type Prompt } from "@/context/prompt"
import { createPromptInputV2Attachments } from "../../session-ui/src/v2/components/prompt-input/attachments"
import type { PromptInputV2Prompt } from "../../session-ui/src/v2/components/prompt-input/types"
+const stored = new Map()
+const persistence = {
+ async putBlob(bytes: Uint8Array) {
+ const digest = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), (byte) =>
+ byte.toString(16).padStart(2, "0"),
+ ).join("")
+ stored.set(digest, bytes.slice())
+ return { digest, byteLength: bytes.byteLength }
+ },
+ async readBlob(reference: { digest: string }) {
+ return stored.get(reference.digest)?.slice() ?? null
+ },
+}
+
describe("prompt attachment session ownership", () => {
+ test("stores attachment bytes before adding the reference to prompt state", async () => {
+ await createRoot(async (dispose) => {
+ const prompt = createPromptState()
+ const called = Promise.withResolvers()
+ const stored = Promise.withResolvers<{ digest: string; byteLength: number }>()
+ const attachments = createPromptAttachmentsCore({
+ capture: prompt.capture,
+ editor: () => document.createElement("div"),
+ putBlob: async () => {
+ called.resolve()
+ return stored.promise
+ },
+ readBlob: async () => null,
+ })
+ const pending = attachments.addAttachment(new File(["content"], "a.txt", { type: "text/plain" }))
+
+ await called.promise
+ expect(images(prompt)).toHaveLength(0)
+ stored.resolve({ digest: "stored", byteLength: 7 })
+ await pending
+
+ expect(images(prompt)).toEqual([expect.objectContaining({ blob: { digest: "stored", byteLength: 7 } })])
+ dispose()
+ })
+ })
+
+ test("loads hydrated attachment bytes only when a preview is requested", async () => {
+ await createRoot(async (dispose) => {
+ const prompt = createPromptState()
+ prompt.set([
+ {
+ type: "image",
+ id: "hydrated",
+ filename: "hydrated.png",
+ mime: "image/png",
+ blob: { digest: "hydrated", byteLength: 3 },
+ },
+ ])
+ let reads = 0
+ const attachments = createPromptAttachmentsCore({
+ capture: prompt.capture,
+ editor: () => document.createElement("div"),
+ putBlob: persistence.putBlob,
+ readBlob: async () => {
+ reads++
+ return new Uint8Array([1, 2, 3])
+ },
+ })
+ const attachment = images(prompt)[0]!
+
+ expect(reads).toBe(0)
+ expect(attachments.previewUrl(attachment)).toBeUndefined()
+ await Bun.sleep(0)
+ expect(reads).toBe(1)
+ expect(attachments.previewUrl(attachment)).toStartWith("blob:")
+ dispose()
+ })
+ })
+
+ test("migrates a persisted data URL to a blob reference", async () => {
+ await createRoot(async (dispose) => {
+ const prompt = createPromptState()
+ prompt.set([
+ {
+ type: "image",
+ id: "legacy",
+ filename: "legacy.txt",
+ mime: "text/plain",
+ dataUrl: "data:text/plain;base64,aGVsbG8=",
+ },
+ ] as unknown as Prompt)
+ createPromptAttachmentsCore({
+ capture: prompt.capture,
+ editor: () => document.createElement("div"),
+ ...persistence,
+ })
+
+ await Bun.sleep(10)
+
+ expect(images(prompt)[0]).toMatchObject({ blob: { byteLength: 5 } })
+ expect(images(prompt)[0]).not.toHaveProperty("dataUrl")
+ dispose()
+ })
+ })
+
test("adds an asynchronously read image to the session where the read started", async () => {
await createRoot(async (dispose) => {
const sessions = { A: createPromptState(), B: createPromptState() }
@@ -14,6 +113,7 @@ describe("prompt attachment session ownership", () => {
const attachments = createPromptAttachmentsCore({
capture: () => sessions[active].capture(),
editor: () => document.createElement("div"),
+ ...persistence,
})
const pending = attachments.addAttachment(new File([new Uint8Array(1024 * 1024)], "a.png", { type: "image/png" }))
@@ -21,6 +121,8 @@ describe("prompt attachment session ownership", () => {
await pending
expect(images(sessions.A)).toHaveLength(1)
+ expect(images(sessions.A)[0]).toMatchObject({ blob: { byteLength: 1024 * 1024 } })
+ expect(images(sessions.A)[0]).not.toHaveProperty("dataUrl")
expect(images(sessions.B)).toHaveLength(0)
dispose()
})
@@ -33,6 +135,7 @@ describe("prompt attachment session ownership", () => {
const attachments = createPromptAttachmentsCore({
capture: prompt.capture,
editor: () => editor,
+ ...persistence,
})
const pending = attachments.addAttachment(new File([new Uint8Array(1024 * 1024)], "a.png", { type: "image/png" }))
@@ -51,10 +154,11 @@ describe("prompt attachment session ownership", () => {
const attachments = createPromptAttachmentsCore({
capture: () => sessions[active].capture(),
editor: () => document.createElement("div"),
+ ...persistence,
})
const pending = attachments.addAttachments([
- new File([new Uint8Array(1024 * 1024)], "first.png", { type: "image/png" }),
- new File([new Uint8Array(1024 * 1024)], "second.png", { type: "image/png" }),
+ new File([new Uint8Array(1024 * 1024).fill(1)], "first.png", { type: "image/png" }),
+ new File([new Uint8Array(1024 * 1024).fill(2)], "second.png", { type: "image/png" }),
])
active = "B"
@@ -74,6 +178,7 @@ describe("prompt attachment session ownership", () => {
const attachments = createPromptAttachmentsCore({
capture: () => sessions[active].capture(),
editor: () => document.createElement("div"),
+ ...persistence,
})
const pending = attachments.addClipboardAttachment(read.promise)
@@ -112,6 +217,7 @@ test("rejects a duplicate native clipboard attachment in the V2 prompt store", a
duplicate: duplicate.resolve,
onError: () => undefined,
readClipboardImage: async () => files.shift() ?? null,
+ ...persistence,
})
const event = {
clipboardData: { items: [], getData: () => "" },
@@ -128,7 +234,49 @@ test("rejects a duplicate native clipboard attachment in the V2 prompt store", a
})
})
-test("rejects desktop duplicates and keeps changed files in the V2 prompt store", async () => {
+test("keeps a new V2 image preview stable when its pending blob read resolves", async () => {
+ await createRoot(async (dispose) => {
+ const [state, setState] = createStore({ prompt: [] as PromptInputV2Prompt })
+ const read = Promise.withResolvers()
+ let attachments: ReturnType | undefined
+ createEffect(() => {
+ const attachment = state.prompt.find((part) => part.type === "image")
+ if (attachment) attachments?.previewUrl(attachment)
+ })
+ attachments = createPromptInputV2Attachments({
+ capture: () => ({
+ current: () => state.prompt,
+ cursor: () => 0,
+ set: (prompt) => setState("prompt", prompt),
+ }),
+ editor: () => document.createElement("div"),
+ focusEditor: () => undefined,
+ addPart: () => false,
+ setDraggingType: () => undefined,
+ directory: () => "/",
+ isDialogActive: () => false,
+ warn: () => undefined,
+ duplicate: () => undefined,
+ onError: () => undefined,
+ putBlob: async (bytes) => ({ digest: "new-image", byteLength: bytes.byteLength }),
+ readBlob: () => read.promise,
+ })
+
+ await attachments.addAttachments([new File([new Uint8Array([1, 2, 3])], "new.png", { type: "image/png" })])
+ const attachment = state.prompt.find((part) => part.type === "image")
+ if (!attachment) throw new Error("Attachment was not added")
+ const preview = attachments.previewUrl(attachment)
+ expect(preview).toStartWith("blob:")
+
+ read.resolve(new Uint8Array([1, 2, 3]))
+ await Bun.sleep(0)
+
+ expect(attachments.previewUrl(attachment)).toBe(preview)
+ dispose()
+ })
+})
+
+test("uses source identity when detecting V2 attachment duplicates", async () => {
await createRoot(async (dispose) => {
const [state, setState] = createStore({ prompt: [] as PromptInputV2Prompt })
const duplicates: string[] = []
@@ -148,6 +296,7 @@ test("rejects desktop duplicates and keeps changed files in the V2 prompt store"
duplicate: () => duplicates.push("duplicate"),
onError: () => undefined,
getPathForFile: (file) => (file.name === "browser.txt" ? "" : `/tmp/${file.name}`),
+ ...persistence,
})
const first = new File(["first"], "a.txt", { type: "text/plain" })
const second = new File(["second"], "b.txt", { type: "text/plain" })
@@ -157,14 +306,21 @@ test("rejects desktop duplicates and keeps changed files in the V2 prompt store"
expect(state.prompt).toHaveLength(2)
expect(duplicates).toEqual(["duplicate", "duplicate"])
+ await attachments.addAttachments([
+ new File(["same"], "c.txt", { type: "text/plain" }),
+ new File(["same"], "d.txt", { type: "text/plain" }),
+ ])
+ expect(state.prompt).toHaveLength(4)
+ expect(duplicates).toHaveLength(2)
+
await attachments.addAttachments([new File(["edited"], "a.txt", { type: "text/plain" })])
- expect(state.prompt).toHaveLength(3)
+ expect(state.prompt).toHaveLength(5)
await attachments.addAttachments([
new File(["same"], "browser.txt", { type: "text/plain" }),
new File(["same"], "browser.txt", { type: "text/plain" }),
])
- expect(state.prompt).toHaveLength(4)
+ expect(state.prompt).toHaveLength(6)
expect(duplicates).toHaveLength(3)
dispose()
})
diff --git a/packages/app/test-browser/prompt-persistence.test.ts b/packages/app/test-browser/prompt-persistence.test.ts
index a7b08078d7e9..db5c40f75532 100644
--- a/packages/app/test-browser/prompt-persistence.test.ts
+++ b/packages/app/test-browser/prompt-persistence.test.ts
@@ -1,19 +1,18 @@
import { beforeAll, describe, expect, mock, test } from "bun:test"
-import type { AsyncStorage } from "@solid-primitives/storage"
+import type { Repository } from "@/persistence"
import { createEffect, createRoot } from "solid-js"
import { ServerScope } from "@/utils/server-scope"
let Prompt: typeof import("@/context/prompt")
let read: ((value: string | null) => void) | undefined
-const storage: AsyncStorage = {
- getItem: () => new Promise((resolve) => (read = resolve)),
- setItem: async () => undefined,
- removeItem: async () => undefined,
- clear: async () => undefined,
- key: async () => null,
- getLength: async () => 0,
- length: Promise.resolve(0),
+const persistence: Repository = {
+ read: () => new Promise((resolve) => (read = resolve)),
+ commit: () => undefined,
+ remove: async () => undefined,
+ putBlob: async (bytes) => ({ digest: "digest", byteLength: bytes.byteLength }),
+ readBlob: async () => null,
+ drain: async () => undefined,
}
beforeAll(async () => {
@@ -30,7 +29,7 @@ beforeAll(async () => {
}),
}))
mock.module("@/context/platform", () => ({
- usePlatform: () => ({ platform: "desktop", storage: () => storage }),
+ usePlatform: () => ({ platform: "desktop", persistence }),
}))
Prompt = await import("@/context/prompt")
diff --git a/packages/app/test-browser/review-panel-v2-state.test.ts b/packages/app/test-browser/review-panel-v2-state.test.ts
index 2a7862ce171a..786fa77f3aad 100644
--- a/packages/app/test-browser/review-panel-v2-state.test.ts
+++ b/packages/app/test-browser/review-panel-v2-state.test.ts
@@ -1,18 +1,17 @@
import { beforeAll, expect, mock, test } from "bun:test"
-import type { AsyncStorage } from "@solid-primitives/storage"
+import type { Repository } from "@/persistence"
import { createEffect, createRoot } from "solid-js"
let createReviewPanelV2State: typeof import("@/pages/session/v2/review-panel-v2-state").createReviewPanelV2State
let read: ((value: string | null) => void) | undefined
-const storage: AsyncStorage = {
- getItem: () => new Promise((resolve) => (read = resolve)),
- setItem: async () => undefined,
- removeItem: async () => undefined,
- clear: async () => undefined,
- key: async () => null,
- getLength: async () => 0,
- length: Promise.resolve(0),
+const persistence: Repository = {
+ read: () => new Promise((resolve) => (read = resolve)),
+ commit: () => undefined,
+ remove: async () => undefined,
+ putBlob: async (bytes) => ({ digest: "digest", byteLength: bytes.byteLength }),
+ readBlob: async () => null,
+ drain: async () => undefined,
}
beforeAll(async () => {
@@ -22,7 +21,7 @@ beforeAll(async () => {
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX: 480,
}))
mock.module("@/context/platform", () => ({
- usePlatform: () => ({ platform: "desktop", storage: () => storage }),
+ usePlatform: () => ({ platform: "desktop", persistence }),
}))
createReviewPanelV2State = (await import("@/pages/session/v2/review-panel-v2-state")).createReviewPanelV2State
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index 582d4c9def11..1632bf9cbd64 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -40,7 +40,6 @@
"@sentry/solid": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@solid-primitives/i18n": "2.2.1",
- "@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.4",
"@types/bun": "catalog:",
diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts
index 556fd26d9666..c57ff1d33c80 100644
--- a/packages/desktop/src/main/index.ts
+++ b/packages/desktop/src/main/index.ts
@@ -14,7 +14,7 @@ import contextMenu from "electron-context-menu"
import type { ServerReadyData } from "../preload/types"
import { checkAppExists, resolveAppPath } from "./apps"
import { CHANNEL } from "./constants"
-import { registerIpcHandlers, sendDeepLinks, sendMenuCommand } from "./ipc"
+import { registerIpcHandlers, requestPersistenceDrains, sendDeepLinks, sendMenuCommand } from "./ipc"
import { forwardInitializationFailure } from "./initialization"
import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
import { createMenu } from "./menu"
@@ -48,6 +48,7 @@ import { spawnWslSidecar } from "./wsl/sidecar"
import { migrate } from "./migrate"
import { cleanupStoreFiles } from "./store-cleanup"
import { startBackgroundCli } from "./background-cli"
+import { openDesktopPersistence, type DesktopPersistence } from "./persistence"
const APP_NAMES: Record = {
dev: "OpenCode Dev",
@@ -65,6 +66,9 @@ const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
let logger: ReturnType
let server: SidecarListener | null = null
+let persistence: DesktopPersistence | undefined
+let persistenceDrainStarted = false
+let persistenceDrained = false
const pendingDeepLinks: string[] = []
@@ -169,10 +173,8 @@ const main = Effect.gen(function* () {
}
const relaunch = () => {
setAppQuitting()
- void stopSidecars().finally(() => {
- app.relaunch()
- app.exit(0)
- })
+ app.relaunch()
+ app.quit()
}
try {
@@ -220,9 +222,19 @@ const main = Effect.gen(function* () {
emitDeepLinks([url])
})
- app.on("before-quit", () => {
+ app.on("before-quit", (event) => {
setAppQuitting()
void stopSidecars()
+ if (!persistence || persistenceDrained) return
+ event.preventDefault()
+ if (persistenceDrainStarted) return
+ persistenceDrainStarted = true
+ void requestPersistenceDrains()
+ .then(() => persistence?.drain())
+ .finally(() => {
+ persistenceDrained = true
+ app.quit()
+ })
})
app.on("will-quit", () => {
@@ -245,7 +257,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())
})
}
@@ -267,10 +279,21 @@ const main = Effect.gen(function* () {
}),
),
)
+ const desktopPersistence = yield* Effect.sync(() =>
+ openDesktopPersistence(join(app.getPath("userData"), "opencode-desktop.db")),
+ )
+ desktopPersistence.importElectronStores(app.getPath("userData"), (message, error) => logger.warn(message, error))
+ const cleaned = desktopPersistence.cleanup()
+ if (cleaned.drafts > 0 || cleaned.blobs > 0) logger.log("cleaned desktop persistence", cleaned)
+ persistence = desktopPersistence
+ app.once("will-quit", () => persistence?.close())
app.setAsDefaultProtocolClient("opencode")
registerRendererProtocol()
setDockIcon()
- const updater = setupAutoUpdater(stopSidecars)
+ const updater = setupAutoUpdater(stopSidecars, async () => {
+ await requestPersistenceDrains()
+ persistence?.drain()
+ })
registerIpcHandlers({
killSidecar: () => killSidecar(),
relaunch,
@@ -298,6 +321,7 @@ const main = Effect.gen(function* () {
setBackgroundColor: (color) => setBackgroundColor(color),
exportDebugLogs: () => exportDebugLogs(),
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
+ persistence,
})
registerWslIpcHandlers(wslServers)
void updater.start()
diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts
index f3208a70f342..284396b3859c 100644
--- a/packages/desktop/src/main/ipc.ts
+++ b/packages/desktop/src/main/ipc.ts
@@ -1,4 +1,5 @@
import { execFile } from "node:child_process"
+import { randomUUID } from "node:crypto"
import { stat } from "node:fs/promises"
import { basename } from "node:path"
import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron"
@@ -9,7 +10,6 @@ import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../prel
import { runDesktopMenuAction } from "./desktop-menu-actions"
import { setForceFocus } from "./debug"
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
-import { getStore, removeStoreFileIfEmpty } from "./store"
import {
getPinchZoomEnabled,
getWindowID,
@@ -21,6 +21,7 @@ import {
} from "./windows"
import type { UpdaterController } from "./updater-controller"
import { createUpdaterSubscriptions } from "./updater-subscriptions"
+import type { DesktopPersistence } from "./persistence"
const pickerFilters = (ext?: string[]) => {
if (!ext || ext.length === 0) return undefined
@@ -48,8 +49,14 @@ type Deps = {
setBackgroundColor: (color: string) => void
exportDebugLogs: () => Promise
recordFatalRendererError: (error: FatalRendererError) => Promise | void
+ persistence: DesktopPersistence
}
+const persistenceDrainRequests = new Map<
+ string,
+ { remaining: Set; finish: () => void; timeout: ReturnType }
+>()
+
export function registerIpcHandlers(deps: Deps) {
const updaterSubscriptions = createUpdaterSubscriptions()
app.once("will-quit", updaterSubscriptions.clear)
@@ -94,34 +101,28 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) =>
deps.recordFatalRendererError(error),
)
- ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
- try {
- const store = getStore(name)
- const value = store.get(key)
- if (value === undefined || value === null) return null
- return typeof value === "string" ? value : JSON.stringify(value)
- } catch {
- return null
- }
- })
- ipcMain.handle("store-set", (_event: IpcMainInvokeEvent, name: string, key: string, value: string) => {
- getStore(name).set(key, value)
- })
- ipcMain.handle("store-delete", (_event: IpcMainInvokeEvent, name: string, key: string) => {
- getStore(name).delete(key)
- void removeStoreFileIfEmpty(name)
- })
- ipcMain.handle("store-clear", (_event: IpcMainInvokeEvent, name: string) => {
- getStore(name).clear()
- void removeStoreFileIfEmpty(name)
- })
- ipcMain.handle("store-keys", (_event: IpcMainInvokeEvent, name: string) => {
- const store = getStore(name)
- return Object.keys(store.store)
- })
- ipcMain.handle("store-length", (_event: IpcMainInvokeEvent, name: string) => {
- const store = getStore(name)
- return Object.keys(store.store).length
+ ipcMain.handle("persistence-read", (_event: IpcMainInvokeEvent, storage: string, key: string) =>
+ deps.persistence.read(storage, key),
+ )
+ ipcMain.handle("persistence-commit", (_event: IpcMainInvokeEvent, storage: string, key: string, value: string) =>
+ deps.persistence.commit(storage, key, value),
+ )
+ ipcMain.handle("persistence-remove", (_event: IpcMainInvokeEvent, storage: string, key: string) =>
+ deps.persistence.remove(storage, key),
+ )
+ ipcMain.handle("persistence-put-blob", (_event: IpcMainInvokeEvent, bytes: Uint8Array) =>
+ deps.persistence.putBlob(bytes),
+ )
+ ipcMain.handle("persistence-read-blob", (_event: IpcMainInvokeEvent, digest: string, byteLength: number) =>
+ deps.persistence.readBlob(digest, byteLength),
+ )
+ ipcMain.handle("persistence-drain", () => deps.persistence.drain())
+ ipcMain.on("persistence-drain-ack", (event: IpcMainEvent, request: string) => {
+ const pending = persistenceDrainRequests.get(request)
+ if (!pending) return
+ pending.remaining.delete(event.sender.id)
+ if (pending.remaining.size > 0) return
+ pending.finish()
})
ipcMain.handle(
@@ -274,6 +275,30 @@ export function registerIpcHandlers(deps: Deps) {
})
}
+export function requestPersistenceDrains(timeoutMs = 1500) {
+ const renderers = BrowserWindow.getAllWindows()
+ .map((window) => window.webContents)
+ .filter((contents) => !contents.isDestroyed())
+ if (renderers.length === 0) return Promise.resolve()
+
+ const request = randomUUID()
+ return new Promise((resolve) => {
+ const finish = () => {
+ const pending = persistenceDrainRequests.get(request)
+ if (!pending) return
+ clearTimeout(pending.timeout)
+ persistenceDrainRequests.delete(request)
+ resolve()
+ }
+ persistenceDrainRequests.set(request, {
+ remaining: new Set(renderers.map((contents) => contents.id)),
+ finish,
+ timeout: setTimeout(finish, timeoutMs),
+ })
+ renderers.forEach((contents) => contents.send("persistence-drain-request", request))
+ })
+}
+
export function sendMenuCommand(win: BrowserWindow, id: string) {
win.webContents.send("menu-command", id)
}
diff --git a/packages/desktop/src/main/persistence.test.ts b/packages/desktop/src/main/persistence.test.ts
new file mode 100644
index 000000000000..426b44b04539
--- /dev/null
+++ b/packages/desktop/src/main/persistence.test.ts
@@ -0,0 +1,155 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import { mkdtemp, rm, writeFile } from "node:fs/promises"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import { DatabaseSync } from "node:sqlite"
+import { openDesktopPersistence } from "./persistence"
+
+const roots: string[] = []
+
+async function tempRoot() {
+ const root = await mkdtemp(join(tmpdir(), "opencode-desktop-persistence-"))
+ roots.push(root)
+ return root
+}
+
+afterEach(async () => {
+ await Promise.all(
+ roots
+ .splice(0)
+ .map((root) => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 }).catch(() => undefined)),
+ )
+})
+
+describe("desktop persistence", () => {
+ test("reads, commits, and removes documents", async () => {
+ const root = await tempRoot()
+ const persistence = openDesktopPersistence(join(root, "desktop.db"))
+
+ expect(persistence.read("global", "theme")).toBeNull()
+ persistence.commit("global", "theme", '"dark"')
+ expect(persistence.read("global", "theme")).toBe('"dark"')
+ persistence.commit("global", "theme", '"light"')
+ expect(persistence.read("global", "theme")).toBe('"light"')
+ persistence.remove("global", "theme")
+ expect(persistence.read("global", "theme")).toBeNull()
+
+ persistence.close()
+ })
+
+ test("stores blobs by digest and verifies reads", async () => {
+ const root = await tempRoot()
+ const path = join(root, "desktop.db")
+ const persistence = openDesktopPersistence(path)
+ const bytes = new TextEncoder().encode("hello")
+
+ const reference = persistence.putBlob(bytes)
+ expect(reference).toEqual({
+ digest: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
+ byteLength: 5,
+ })
+ expect(persistence.putBlob(bytes)).toEqual(reference)
+ expect(persistence.readBlob(reference.digest, reference.byteLength)).toEqual(bytes)
+
+ const database = new DatabaseSync(path)
+ database.prepare("UPDATE blob SET byte_length = 1 WHERE digest = ?").run(reference.digest)
+ database.close()
+ expect(() => persistence.readBlob(reference.digest, reference.byteLength)).toThrow("Blob reference mismatch")
+
+ const corrupted = new DatabaseSync(path)
+ corrupted
+ .prepare("UPDATE blob SET bytes = ?, byte_length = ? WHERE digest = ?")
+ .run(new TextEncoder().encode("world"), reference.byteLength, reference.digest)
+ corrupted.close()
+ expect(() => persistence.readBlob(reference.digest, reference.byteLength)).toThrow("Blob digest mismatch")
+ persistence.close()
+ })
+
+ test("imports electron stores once without replacing newer documents", async () => {
+ const root = await tempRoot()
+ const history = JSON.stringify([
+ {
+ prompt: [
+ {
+ type: "image",
+ id: "image-1",
+ filename: "image.png",
+ mime: "image/png",
+ dataUrl: "data:image/png;base64,aGVsbG8=",
+ },
+ ],
+ comments: [],
+ },
+ ])
+ await writeFile(join(root, "opencode.global.dat"), JSON.stringify({ theme: '"dark"', count: 2, history }))
+ await writeFile(join(root, "opencode.settings"), JSON.stringify({ serverUrl: "http://localhost" }))
+ const persistence = openDesktopPersistence(join(root, "desktop.db"))
+
+ persistence.commit("opencode.global.dat", "theme", '"newer"')
+ persistence.importElectronStores(root)
+ expect(persistence.read("opencode.global.dat", "theme")).toBe('"newer"')
+ expect(persistence.read("opencode.global.dat", "count")).toBe("2")
+ expect(persistence.read("opencode.settings", "serverUrl")).toBe("http://localhost")
+ expect(JSON.parse(persistence.read("opencode.global.dat", "history")!)[0].prompt[0]).toEqual({
+ type: "image",
+ id: "image-1",
+ filename: "image.png",
+ mime: "image/png",
+ blob: {
+ digest: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
+ byteLength: 5,
+ },
+ })
+
+ await writeFile(join(root, "opencode.global.dat"), JSON.stringify({ added: true }))
+ persistence.importElectronStores(root)
+ expect(persistence.read("opencode.global.dat", "added")).toBeNull()
+ persistence.close()
+ })
+
+ test("prunes stale drafts and unreferenced blobs", async () => {
+ const root = await tempRoot()
+ const path = join(root, "desktop.db")
+ const persistence = openDesktopPersistence(path)
+ const stale = persistence.putBlob(new TextEncoder().encode("stale"))
+ const retained = persistence.putBlob(new TextEncoder().encode("retained"))
+ const orphaned = persistence.putBlob(new TextEncoder().encode("orphaned"))
+ persistence.commit("opencode.draft.stale.dat", "draft:prompt", JSON.stringify({ blob: stale }))
+ persistence.commit("opencode.draft.recent.dat", "draft:prompt", JSON.stringify({ blob: retained }))
+
+ const database = new DatabaseSync(path)
+ const now = Date.now()
+ database
+ .prepare("UPDATE document SET updated_at = ? WHERE storage = ?")
+ .run(now - 31 * 24 * 60 * 60 * 1000, "opencode.draft.stale.dat")
+ database.close()
+
+ expect(persistence.cleanup(now)).toEqual({ drafts: 1, blobs: 2 })
+ expect(persistence.read("opencode.draft.stale.dat", "draft:prompt")).toBeNull()
+ expect(persistence.read("opencode.draft.recent.dat", "draft:prompt")).not.toBeNull()
+ expect(persistence.readBlob(stale.digest)).toBeNull()
+ expect(persistence.readBlob(orphaned.digest)).toBeNull()
+ expect(persistence.readBlob(retained.digest)).not.toBeNull()
+ persistence.close()
+ })
+
+ test("keeps the newest 100 drafts", async () => {
+ const root = await tempRoot()
+ const path = join(root, "desktop.db")
+ const persistence = openDesktopPersistence(path)
+ const now = Date.now()
+ const drafts = Array.from({ length: 101 }, (_, index) => index)
+ drafts.forEach((index) => {
+ persistence.commit(`opencode.draft.${index}.dat`, "draft:prompt", `{"index":${index}}`)
+ })
+ const database = new DatabaseSync(path)
+ const update = database.prepare("UPDATE document SET updated_at = ? WHERE storage = ?")
+ drafts.forEach((index) => update.run(now - index, `opencode.draft.${index}.dat`))
+ database.close()
+
+ expect(persistence.cleanup(now).drafts).toBe(1)
+ expect(persistence.read("opencode.draft.0.dat", "draft:prompt")).not.toBeNull()
+ expect(persistence.read("opencode.draft.100.dat", "draft:prompt")).toBeNull()
+ persistence.close()
+ })
+})
diff --git a/packages/desktop/src/main/persistence.ts b/packages/desktop/src/main/persistence.ts
new file mode 100644
index 000000000000..a888ddd59227
--- /dev/null
+++ b/packages/desktop/src/main/persistence.ts
@@ -0,0 +1,210 @@
+import { createHash } from "node:crypto"
+import { readFileSync, readdirSync } from "node:fs"
+import { join } from "node:path"
+import { DatabaseSync } from "node:sqlite"
+
+const STORE_MIGRATION = "electron-store-v1"
+const DRAFT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
+const DRAFT_KEEP_RECENT = 100
+
+export type DesktopPersistence = ReturnType
+
+export function openDesktopPersistence(path: string) {
+ const database = new DatabaseSync(path)
+ database.exec(`
+ PRAGMA journal_mode = WAL;
+ PRAGMA synchronous = NORMAL;
+ PRAGMA busy_timeout = 5000;
+ PRAGMA foreign_keys = ON;
+
+ CREATE TABLE IF NOT EXISTS document (
+ storage TEXT NOT NULL,
+ key TEXT NOT NULL,
+ value TEXT NOT NULL,
+ updated_at INTEGER NOT NULL,
+ PRIMARY KEY (storage, key)
+ );
+
+ CREATE TABLE IF NOT EXISTS blob (
+ digest TEXT PRIMARY KEY,
+ bytes BLOB NOT NULL,
+ byte_length INTEGER NOT NULL,
+ created_at INTEGER NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS migration (
+ name TEXT PRIMARY KEY,
+ completed_at INTEGER NOT NULL
+ );
+ `)
+
+ const readDocument = database.prepare("SELECT value FROM document WHERE storage = ? AND key = ?")
+ const commitDocument = database.prepare(`
+ INSERT INTO document (storage, key, value, updated_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT (storage, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
+ `)
+ const removeDocument = database.prepare("DELETE FROM document WHERE storage = ? AND key = ?")
+ const insertBlob = database.prepare(`
+ INSERT OR IGNORE INTO blob (digest, bytes, byte_length, created_at)
+ VALUES (?, ?, ?, ?)
+ `)
+ const selectBlob = database.prepare("SELECT bytes, byte_length FROM blob WHERE digest = ?")
+ const selectMigration = database.prepare("SELECT 1 AS found FROM migration WHERE name = ?")
+ const insertMigration = database.prepare("INSERT INTO migration (name, completed_at) VALUES (?, ?)")
+ const importDocument = database.prepare(`
+ INSERT OR IGNORE INTO document (storage, key, value, updated_at)
+ VALUES (?, ?, ?, ?)
+ `)
+ const selectDrafts = database.prepare(`
+ SELECT storage, MAX(updated_at) AS updated_at
+ FROM document
+ WHERE storage LIKE 'opencode.draft.%.dat'
+ GROUP BY storage
+ ORDER BY updated_at DESC
+ `)
+ const deleteStorage = database.prepare("DELETE FROM document WHERE storage = ?")
+ const selectDocuments = database.prepare("SELECT value FROM document")
+ const selectBlobs = database.prepare("SELECT digest FROM blob")
+ const deleteBlob = database.prepare("DELETE FROM blob WHERE digest = ?")
+
+ const readBlob = (digest: string, byteLength?: number) => {
+ const row = selectBlob.get(digest) as { bytes: Uint8Array; byte_length: number } | undefined
+ if (!row) return null
+ const bytes = new Uint8Array(row.bytes)
+ if (byteLength !== undefined && row.byte_length !== byteLength) {
+ throw new Error(`Blob reference mismatch: ${digest}`)
+ }
+ if (bytes.byteLength !== row.byte_length) throw new Error(`Blob length mismatch: ${digest}`)
+ if (sha256(bytes) !== digest) throw new Error(`Blob digest mismatch: ${digest}`)
+ return bytes
+ }
+
+ return {
+ read(storage: string, key: string) {
+ const row = readDocument.get(storage, key) as { value: string } | undefined
+ return row?.value ?? null
+ },
+ commit(storage: string, key: string, value: string) {
+ commitDocument.run(storage, key, value, Date.now())
+ },
+ remove(storage: string, key: string) {
+ removeDocument.run(storage, key)
+ },
+ putBlob(bytes: Uint8Array) {
+ const value = new Uint8Array(bytes)
+ const digest = sha256(value)
+ insertBlob.run(digest, value, value.byteLength, Date.now())
+ readBlob(digest, value.byteLength)
+ return { digest, byteLength: value.byteLength }
+ },
+ readBlob,
+ drain() {
+ database.exec("PRAGMA wal_checkpoint(PASSIVE)")
+ },
+ importElectronStores(userDataPath: string, warn: (message: string, error: unknown) => void = () => undefined) {
+ if (selectMigration.get(STORE_MIGRATION)) return
+
+ const documents = readdirSync(userDataPath, { withFileTypes: true })
+ .filter((entry) => entry.isFile() && (entry.name.endsWith(".dat") || entry.name === "opencode.settings"))
+ .flatMap((entry) => {
+ try {
+ const value: unknown = JSON.parse(readFileSync(join(userDataPath, entry.name), "utf8"))
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ throw new Error("Store root must be an object")
+ }
+ return Object.entries(value).map(([key, item]) => ({ storage: entry.name, key, item }))
+ } catch (error) {
+ warn(`failed to import ${entry.name}`, error)
+ return []
+ }
+ })
+
+ database.exec("BEGIN IMMEDIATE")
+ try {
+ const now = Date.now()
+ documents.forEach((item) => importDocument.run(item.storage, item.key, migrateStoredValue(item.item), now))
+ insertMigration.run(STORE_MIGRATION, now)
+ database.exec("COMMIT")
+ } catch (error) {
+ database.exec("ROLLBACK")
+ throw error
+ }
+ },
+ cleanup(now = Date.now()) {
+ const drafts = selectDrafts.all() as Array<{ storage: string; updated_at: number }>
+ const stale = drafts.filter(
+ (draft, index) => now - draft.updated_at > DRAFT_RETENTION_MS || index >= DRAFT_KEEP_RECENT,
+ )
+ const removed = { blobs: 0 }
+
+ database.exec("BEGIN IMMEDIATE")
+ try {
+ stale.forEach((draft) => deleteStorage.run(draft.storage))
+ const referenced = new Set()
+ for (const row of selectDocuments.all() as Array<{ value: string }>) {
+ try {
+ collectBlobReferences(JSON.parse(row.value), referenced)
+ } catch {}
+ }
+ const orphaned = (selectBlobs.all() as Array<{ digest: string }>).filter((blob) => !referenced.has(blob.digest))
+ orphaned.forEach((blob) => deleteBlob.run(blob.digest))
+ removed.blobs = orphaned.length
+ database.exec("COMMIT")
+ } catch (error) {
+ database.exec("ROLLBACK")
+ throw error
+ }
+ return { drafts: stale.length, blobs: removed.blobs }
+ },
+ close() {
+ database.close()
+ },
+ }
+
+ function migrateStoredValue(value: unknown) {
+ if (typeof value !== "string") return JSON.stringify(migrateAttachments(value)) ?? "null"
+ try {
+ return JSON.stringify(migrateAttachments(JSON.parse(value)))
+ } catch {
+ return value
+ }
+ }
+
+ function migrateAttachments(value: unknown): unknown {
+ if (Array.isArray(value)) return value.map(migrateAttachments)
+ if (!value || typeof value !== "object") return value
+ const record = value as Record
+ if (record.type === "image" && typeof record.dataUrl === "string") {
+ const match = /^data:[^;,]+;base64,(.*)$/s.exec(record.dataUrl)
+ if (!match) return value
+ const bytes = new Uint8Array(Buffer.from(match[1], "base64"))
+ const digest = sha256(bytes)
+ insertBlob.run(digest, bytes, bytes.byteLength, Date.now())
+ return Object.fromEntries(
+ Object.entries(record)
+ .filter(([key]) => key !== "dataUrl")
+ .concat([["blob", { digest, byteLength: bytes.byteLength }]]),
+ )
+ }
+ return Object.fromEntries(Object.entries(record).map(([key, item]) => [key, migrateAttachments(item)]))
+ }
+}
+
+function sha256(bytes: Uint8Array) {
+ return createHash("sha256").update(bytes).digest("hex")
+}
+
+function collectBlobReferences(value: unknown, references: Set) {
+ if (Array.isArray(value)) {
+ value.forEach((item) => collectBlobReferences(item, references))
+ return
+ }
+ if (!value || typeof value !== "object") return
+ const record = value as Record
+ const blob = record.blob
+ if (blob && typeof blob === "object" && "digest" in blob && typeof blob.digest === "string") {
+ references.add(blob.digest)
+ }
+ Object.values(record).forEach((item) => collectBlobReferences(item, references))
+}
diff --git a/packages/desktop/src/main/updater-controller.ts b/packages/desktop/src/main/updater-controller.ts
index a0ae1a892745..5e171012d2b1 100644
--- a/packages/desktop/src/main/updater-controller.ts
+++ b/packages/desktop/src/main/updater-controller.ts
@@ -7,7 +7,7 @@ export type UpdaterReadyRecord = { version: string }
export type UpdaterBackend = {
checkForUpdates(): Promise<{ isUpdateAvailable?: boolean; updateInfo?: { version?: string } } | null | undefined>
downloadUpdate(): Promise
- quitAndInstall(): void
+ quitAndInstall(): void | Promise
}
type UpdaterPersistence = {
@@ -82,8 +82,8 @@ export function createUpdaterController(input: {
transition({ status: "installing", version })
await input
.stop()
- .then(() => {
- input.backend.quitAndInstall()
+ .then(async () => {
+ await input.backend.quitAndInstall()
transition({ status: "ready", version })
})
.catch((error) => {
diff --git a/packages/desktop/src/main/updater.ts b/packages/desktop/src/main/updater.ts
index cd483ed72fea..95ac90afea41 100644
--- a/packages/desktop/src/main/updater.ts
+++ b/packages/desktop/src/main/updater.ts
@@ -9,7 +9,7 @@ import { setAppQuitting } from "./windows"
const { autoUpdater } = pkg
const key = "ready"
-export function setupAutoUpdater(stop: () => Promise) {
+export function setupAutoUpdater(stop: () => Promise, drain: () => Promise) {
const logger = getLogger()
autoUpdater.logger = logger
autoUpdater.channel = "latest"
@@ -31,11 +31,12 @@ export function setupAutoUpdater(stop: () => Promise) {
backend: {
checkForUpdates: () => autoUpdater.checkForUpdates(),
downloadUpdate: () => autoUpdater.downloadUpdate(),
- quitAndInstall: () => {
+ quitAndInstall: async () => {
// quitAndInstall closes all windows before emitting before-quit, so
// flag the quit first to keep window ids persisted for restore.
setAppQuitting()
try {
+ await drain()
autoUpdater.quitAndInstall()
} catch (error) {
// The install failed and the app keeps running; clear the flag so
diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts
index 270ff4e576f0..3fa3214c426a 100644
--- a/packages/desktop/src/preload/index.ts
+++ b/packages/desktop/src/preload/index.ts
@@ -67,12 +67,20 @@ const api: ElectronAPI = {
setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend),
checkAppExists: (appName) => ipcRenderer.invoke("check-app-exists", appName),
resolveAppPath: (appName) => ipcRenderer.invoke("resolve-app-path", appName),
- storeGet: (name, key) => ipcRenderer.invoke("store-get", name, key),
- storeSet: (name, key, value) => ipcRenderer.invoke("store-set", name, key, value),
- storeDelete: (name, key) => ipcRenderer.invoke("store-delete", name, key),
- storeClear: (name) => ipcRenderer.invoke("store-clear", name),
- storeKeys: (name) => ipcRenderer.invoke("store-keys", name),
- storeLength: (name) => ipcRenderer.invoke("store-length", name),
+ persistence: {
+ read: (storage, key) => ipcRenderer.invoke("persistence-read", storage, key),
+ commit: (storage, key, value) => ipcRenderer.invoke("persistence-commit", storage, key, value),
+ remove: (storage, key) => ipcRenderer.invoke("persistence-remove", storage, key),
+ putBlob: (bytes) => ipcRenderer.invoke("persistence-put-blob", bytes),
+ readBlob: (digest, byteLength) => ipcRenderer.invoke("persistence-read-blob", digest, byteLength),
+ drain: () => ipcRenderer.invoke("persistence-drain"),
+ onDrainRequest: (cb) => {
+ const handler = (_: unknown, request: string) => cb(request)
+ ipcRenderer.on("persistence-drain-request", handler)
+ return () => ipcRenderer.removeListener("persistence-drain-request", handler)
+ },
+ acknowledgeDrain: (request) => ipcRenderer.send("persistence-drain-ack", request),
+ },
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..9535f3e5b560 100644
--- a/packages/desktop/src/preload/types.ts
+++ b/packages/desktop/src/preload/types.ts
@@ -41,6 +41,17 @@ export type FatalRendererError = {
os?: string
}
+export type PersistenceAPI = {
+ read: (storage: string, key: string) => Promise
+ commit: (storage: string, key: string, value: string) => Promise
+ remove: (storage: string, key: string) => Promise
+ putBlob: (bytes: Uint8Array) => Promise<{ digest: string; byteLength: number }>
+ readBlob: (digest: string, byteLength: number) => Promise
+ drain: () => Promise
+ onDrainRequest: (cb: (request: string) => void) => () => void
+ acknowledgeDrain: (request: string) => void
+}
+
export type ElectronAPI = {
killSidecar: () => Promise
installCli: () => Promise
@@ -57,12 +68,7 @@ export type ElectronAPI = {
setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise
checkAppExists: (appName: string) => Promise
resolveAppPath: (appName: string) => Promise
- storeGet: (name: string, key: string) => Promise
- storeSet: (name: string, key: string, value: string) => Promise
- storeDelete: (name: string, key: string) => Promise
- storeClear: (name: string) => Promise
- storeKeys: (name: string) => Promise
- storeLength: (name: string) => Promise
+ persistence: PersistenceAPI
getWindowID: () => Promise
onMenuCommand: (cb: (id: string) => void) => () => void
diff --git a/packages/desktop/src/renderer/i18n/index.ts b/packages/desktop/src/renderer/i18n/index.ts
index a88fed708027..836c0b64a9ac 100644
--- a/packages/desktop/src/renderer/i18n/index.ts
+++ b/packages/desktop/src/renderer/i18n/index.ts
@@ -180,7 +180,7 @@ export function initI18n(): Promise {
if (cached) return cached
const promise = (async () => {
- const raw = await window.api.storeGet("opencode.global.dat", "language").catch(() => null)
+ const raw = await window.api.persistence.read("opencode.global.dat", "language").catch(() => null)
const value = parseStored(raw)
const next = pickLocale(value) ?? state.locale
diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx
index 244b5eb405a3..bea3ad94baa2 100644
--- a/packages/desktop/src/renderer/index.tsx
+++ b/packages/desktop/src/renderer/index.tsx
@@ -6,6 +6,8 @@ import {
AppInterface,
loadLocaleDict,
normalizeLocale,
+ createRepository,
+ type DurableRepository,
type Locale,
type Platform,
PlatformProvider,
@@ -15,7 +17,6 @@ import {
} from "@opencode-ai/app"
import type { UpdaterState } from "@opencode-ai/app/updater"
import * as Sentry from "@sentry/solid"
-import type { AsyncStorage } from "@solid-primitives/storage"
import { createMemoryHistory, MemoryRouter, type BaseRouterProps } from "@solidjs/router"
import { createEffect, createMemo, createResource, createSignal, onCleanup, Show } from "solid-js"
import { render } from "solid-js/web"
@@ -136,32 +137,21 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
return window.api.runDesktopMenuAction(action)
}
- const storage = (() => {
- const cache = new Map()
-
- const createStorage = (name: string) => {
- const api: AsyncStorage = {
- getItem: (key: string) => window.api.storeGet(name, key),
- setItem: (key: string, value: string) => window.api.storeSet(name, key, value),
- removeItem: (key: string) => window.api.storeDelete(name, key),
- clear: () => window.api.storeClear(name),
- key: async (index: number) => (await window.api.storeKeys(name))[index],
- getLength: () => window.api.storeLength(name),
- get length() {
- return api.getLength()
- },
- }
- return api
- }
-
- return (name = "default.dat") => {
- const cached = cache.get(name)
- if (cached) return cached
- const api = createStorage(name)
- cache.set(name, api)
- return api
- }
- })()
+ const durable: DurableRepository = {
+ read: (address) => window.api.persistence.read(address.storage, address.key),
+ commit: (input) => window.api.persistence.commit(input.address.storage, input.address.key, input.value),
+ remove: (address) => window.api.persistence.remove(address.storage, address.key),
+ putBlob: (bytes) => window.api.persistence.putBlob(bytes),
+ readBlob: (reference) => window.api.persistence.readBlob(reference.digest, reference.byteLength),
+ drain: () => window.api.persistence.drain(),
+ }
+ const persistence = createRepository(durable)
+ window.api.persistence.onDrainRequest((request) => {
+ void persistence.drain().then(
+ () => window.api.persistence.acknowledgeDrain(request),
+ () => window.api.persistence.acknowledgeDrain(request),
+ )
+ })
const wslServersApi = os === "windows" ? window.api.wslServers : undefined
@@ -225,7 +215,7 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
return window.api.revealPath(path)
},
- storage,
+ persistence,
updater: {
state: updaterState,
@@ -327,8 +317,10 @@ function LoadingSplash() {
function DesktopRoot(props: { windowState: DesktopWindowState }) {
const platform = createPlatform(props.windowState)
const loadLocale = async () => {
- const current = await platform.storage?.("opencode.global.dat").getItem("language")
- const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
+ const current = await platform.persistence!.read({ storage: "opencode.global.dat", key: "language" })
+ const legacy = current
+ ? undefined
+ : await platform.persistence!.read({ storage: "default.dat", key: "language.v1" })
const raw = current ?? legacy
if (!raw) return
const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1]
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..9b1644326021 100644
--- a/packages/session-ui/src/v2/components/prompt-input/attachments.ts
+++ b/packages/session-ui/src/v2/components/prompt-input/attachments.ts
@@ -1,6 +1,7 @@
-import { onMount } from "solid-js"
+import { createEffect, onCleanup, onMount } from "solid-js"
+import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
-import type { PromptInputV2Attachment, PromptInputV2Prompt } from "./types"
+import type { PromptInputV2Attachment, PromptInputV2BlobReference, PromptInputV2Prompt } from "./types"
const accepted = [
"image/png",
@@ -78,6 +79,8 @@ export type PromptInputV2AttachmentConfig = {
onError: (error: unknown) => void
readClipboardImage?: () => Promise
getPathForFile?: (file: File) => string
+ putBlob: (bytes: Uint8Array) => Promise
+ readBlob: (reference: PromptInputV2BlobReference) => Promise
}
export function createPromptInputV2Attachments(
@@ -89,6 +92,97 @@ export function createPromptInputV2Attachments(
setDraggingType: (type: "image" | "@mention" | null) => void
},
) {
+ const [previews, setPreviews] = createStore>({})
+ const loading = new Set()
+ const migrating = new Set()
+ const revoke = (digest: string) => {
+ const url = previews[digest]
+ if (url) URL.revokeObjectURL(url)
+ setPreviews(digest, undefined)
+ loading.delete(digest)
+ }
+ const cachePreview = (attachment: PromptInputV2Attachment, bytes: Uint8Array) => {
+ const reference = attachmentReference(attachment)
+ if (!reference) return
+ const previous = previews[reference.digest]
+ const next = URL.createObjectURL(new Blob([bytes.slice().buffer], { type: attachment.mime }))
+ setPreviews(reference.digest, next)
+ loading.delete(reference.digest)
+ if (previous) URL.revokeObjectURL(previous)
+ }
+ const previewUrl = (attachment: PromptInputV2Attachment) => {
+ const reference = attachmentReference(attachment)
+ if (!reference) return
+ const digest = reference.digest
+ const current = previews[digest]
+ if (current || loading.has(digest)) return current
+ loading.add(digest)
+ void input
+ .readBlob(reference)
+ .then((bytes) => {
+ if (!bytes || previews[digest]) {
+ loading.delete(digest)
+ return
+ }
+ if (
+ !input
+ .capture()
+ .current()
+ .some((part) => part.type === "image" && attachmentReference(part)?.digest === digest)
+ ) {
+ loading.delete(digest)
+ return
+ }
+ cachePreview(attachment, bytes)
+ })
+ .catch(() => loading.delete(digest))
+ return previews[digest]
+ }
+ createEffect(() => {
+ const target = input.capture()
+ target.current().forEach((part) => {
+ if (part.type !== "image") return
+ const url = legacyAttachmentUrl(part)
+ if (!url || migrating.has(part.id)) return
+ migrating.add(part.id)
+ void fetch(url)
+ .then((response) => response.arrayBuffer())
+ .then((buffer) => {
+ const bytes = new Uint8Array(buffer)
+ return input.putBlob(bytes).then((blob) => ({ bytes, blob }))
+ })
+ .then(({ bytes, blob }) => {
+ const current = target.current()
+ if (!current.some((item) => item.type === "image" && item.id === part.id && legacyAttachmentUrl(item))) return
+ const attachment: PromptInputV2Attachment = {
+ type: "image",
+ id: part.id,
+ filename: part.filename,
+ sourcePath: part.sourcePath,
+ mime: part.mime,
+ blob,
+ }
+ target.set(
+ current.map((item) => (item.type === "image" && item.id === part.id ? attachment : item)),
+ target.cursor(),
+ )
+ cachePreview(attachment, bytes)
+ })
+ .catch(() => {})
+ .finally(() => migrating.delete(part.id))
+ })
+ const active = new Set(
+ target.current().flatMap((part) => {
+ const reference = part.type === "image" ? attachmentReference(part) : undefined
+ return reference ? [reference.digest] : []
+ }),
+ )
+ Object.keys(previews).forEach((digest) => {
+ if (!active.has(digest)) revoke(digest)
+ })
+ })
+ onCleanup(() => Object.keys(previews).forEach(revoke))
+
const capture = () => {
const prompt = input.capture()
const editor = input.editor()
@@ -102,17 +196,15 @@ export function createPromptInputV2Attachments(
if (toast) input.warn()
return false
}
- const url = await dataUrl(file, mime)
- if (!url) return false
+ const bytes = new Uint8Array(await file.arrayBuffer())
+ const blob = await input.putBlob(bytes)
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.
const duplicate = target.prompt
.current()
.some(
(part) =>
part.type === "image" &&
- part.dataUrl === url &&
+ attachmentReference(part)?.digest === blob.digest &&
(sourcePath
? part.sourcePath === sourcePath
: !part.sourcePath && (clipboard || part.filename === file.name)),
@@ -127,9 +219,10 @@ export function createPromptInputV2Attachments(
filename: file.name,
sourcePath,
mime,
- dataUrl: url,
+ blob,
}
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
+ cachePreview(attachment, bytes)
return true
}
const addAttachments = async (files: File[], toast = true, target = capture()) => {
@@ -205,6 +298,8 @@ export function createPromptInputV2Attachments(
return {
addAttachments,
+ previewUrl,
+ revoke,
handlePaste,
handleDrop,
pick(fallback: () => void) {
@@ -219,17 +314,13 @@ 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)
- })
+function attachmentReference(attachment: PromptInputV2Attachment) {
+ return (attachment as PromptInputV2Attachment & { blob?: PromptInputV2BlobReference }).blob
+}
+
+function legacyAttachmentUrl(attachment: PromptInputV2Attachment) {
+ const value = (attachment as PromptInputV2Attachment & { dataUrl?: unknown }).dataUrl
+ return typeof value === "string" && value.startsWith("data:") ? value : undefined
}
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
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..70303d78c4f2 100644
--- a/packages/session-ui/src/v2/components/prompt-input/index.tsx
+++ b/packages/session-ui/src/v2/components/prompt-input/index.tsx
@@ -14,6 +14,7 @@ import { CommentCardV2 } from "../comment-card-v2"
import { typeLabel } from "../../../components/message-file"
import type {
PromptInputV2Attachment,
+ PromptInputV2BlobReference,
PromptInputV2Comment,
PromptInputV2Option,
PromptInputV2PersistedState,
@@ -25,6 +26,7 @@ import "./attachments.css"
export type {
PromptInputV2Attachment,
+ PromptInputV2BlobReference,
PromptInputV2Comment,
PromptInputV2Option,
PromptInputV2PersistedState,
@@ -134,6 +136,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
comments={props.controller.comments()}
activeCommentID={state.activeContextID}
removeLabel="Remove attachment"
+ previewUrl={props.controller.attachmentUrl}
onAttachmentClick={props.controller.openAttachment}
onAttachmentRemove={(attachment) => props.controller.removeAttachment(attachment.id)}
onCommentClick={(comment) => props.controller.toggleContext(comment.key)}
@@ -372,6 +375,7 @@ export function PromptInputV2Attachments(props: {
comments?: PromptInputV2Comment[]
activeCommentID?: string
removeLabel: string
+ previewUrl: (attachment: PromptInputV2Attachment) => string | undefined
onAttachmentClick?: (attachment: PromptInputV2Attachment) => void
onAttachmentRemove: (attachment: PromptInputV2Attachment) => void
onCommentClick?: (comment: PromptInputV2Comment) => void
@@ -425,7 +429,7 @@ export function PromptInputV2Attachments(props: {
}
>
props.onAttachmentClick?.(attachment)}
diff --git a/packages/session-ui/src/v2/components/prompt-input/interaction.ts b/packages/session-ui/src/v2/components/prompt-input/interaction.ts
index f4a9fa74b7f2..a64de0489af2 100644
--- a/packages/session-ui/src/v2/components/prompt-input/interaction.ts
+++ b/packages/session-ui/src/v2/components/prompt-input/interaction.ts
@@ -61,7 +61,7 @@ export function createPromptInputV2Controller(input: {
commands: Accessor
context: Accessor
searchContextFiles: (query: string) => PromptInputV2Suggestion[] | Promise
- openAttachment?: (attachment: PromptInputV2Attachment) => void
+ openAttachment?: (attachment: PromptInputV2Attachment, previewUrl?: string) => void
openContext?: (key: string) => void
onContextRemove?: (item: PromptInputV2Comment) => void
onEditor?: (element: HTMLElement) => void
@@ -322,9 +322,16 @@ export function createPromptInputV2Controller(input: {
if (state.activeContextID === id) dispatch({ type: "context.active", id })
},
openAttachment(attachment: PromptInputV2Attachment) {
- input.openAttachment?.(attachment)
+ input.openAttachment?.(attachment, attachments?.previewUrl(attachment))
+ },
+ attachmentUrl(attachment: PromptInputV2Attachment) {
+ return attachments?.previewUrl(attachment)
},
removeAttachment(id: string) {
+ const attachment = draft.state.prompt.find(
+ (part): part is PromptInputV2Attachment => part.type === "image" && part.id === id,
+ )
+ if (attachment?.blob) attachments?.revoke(attachment.blob.digest)
draft.removeAttachment(id)
},
canSubmit() {
@@ -443,7 +450,11 @@ function canNavigateHistory(direction: "up" | "down", text: string, cursor: numb
function clonePrompt(prompt: PromptInputV2PersistedState["prompt"]): PromptInputV2PersistedState["prompt"] {
return prompt.map((part) =>
- part.type === "file" ? { ...part, selection: part.selection ? { ...part.selection } : undefined } : { ...part },
+ part.type === "file"
+ ? { ...part, selection: part.selection ? { ...part.selection } : undefined }
+ : part.type === "image"
+ ? { ...part, blob: { ...part.blob } }
+ : { ...part },
)
}
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..7b0c62239ef9 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: { digest: "requirements", byteLength: 14 },
},
],
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: { digest: `attachment-${store.state.prompt.length}`, byteLength: 0 },
})
}
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..1f0d2229b774 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: { digest: "notes", byteLength: 0 },
},
],
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: { digest: "notes", byteLength: 0 },
},
])
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..0418996af2a1 100644
--- a/packages/session-ui/src/v2/components/prompt-input/types.ts
+++ b/packages/session-ui/src/v2/components/prompt-input/types.ts
@@ -25,13 +25,18 @@ export type PromptInputV2AgentPart = PromptInputV2PartBase & {
name: string
}
+export type PromptInputV2BlobReference = {
+ digest: string
+ byteLength: number
+}
+
export type PromptInputV2Attachment = {
type: "image"
id: string
filename: string
sourcePath?: string
mime: string
- dataUrl: string
+ blob: PromptInputV2BlobReference
}
export type PromptInputV2Prompt = (