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 } > {attachment.filename} 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: { } > {attachment.filename} 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 = (