Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 12 additions & 16 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
21 changes: 14 additions & 7 deletions packages/app/src/components/dialog-fork.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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}`)
Expand Down
10 changes: 8 additions & 2 deletions packages/app/src/components/prompt-input-v2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -343,8 +343,9 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
onContextRemove(item) {
if (item?.commentID) comments.remove(item.path, item.commentID)
},
openAttachment: (attachment) =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />),
openAttachment: (attachment, previewUrl) => {
if (previewUrl) dialog.show(() => <ImagePreview src={previewUrl} alt={attachment.filename} />)
},
openContext(key) {
const item = controller.contextItem(key)
if (item) openComment(item, props, sync, layout, files, comments)
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 11 additions & 4 deletions packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1160,7 +1160,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return true
}

const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
const { addAttachment, addAttachments, removeAttachment, handlePaste, previewUrl } = createPromptAttachments({
prompt,
editor: () => editorRef,
isDialogActive: () => !!dialog.active,
Expand All @@ -1172,6 +1172,11 @@ export const PromptInput: Component<PromptInputProps> = (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 = () => (
Expand Down Expand Up @@ -1488,9 +1493,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
/>
<PromptImageAttachments
attachments={imageAttachments()}
onOpen={(attachment) =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
}
previewUrl={previewUrl}
onOpen={(attachment) => {
const src = previewUrl(attachment)
if (src) dialog.show(() => <ImagePreview src={src} alt={attachment.filename} />)
}}
onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")}
newLayoutDesigns={false}
Expand Down
136 changes: 115 additions & 21 deletions packages/app/src/components/prompt-input/attachments.ts
Original file line number Diff line number Diff line change
@@ -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<string>((resolve) => {
const reader = new FileReader()
reader.addEventListener("error", () => resolve(""))
reader.addEventListener("load", () => {
const value = typeof reader.result === "string" ? reader.result : ""
const idx = value.indexOf(",")
if (idx === -1) {
resolve(value)
return
}
resolve(`data:${mime};base64,${value.slice(idx + 1)}`)
})
reader.readAsDataURL(file)
})
}

type PromptTarget = Pick<ReturnType<ReturnType<typeof usePrompt>["capture"]>, "current" | "cursor" | "set">
type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined }

Expand All @@ -36,6 +21,8 @@ type PromptAttachmentsCoreInput = {
warn?: () => void
readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string
putBlob: (bytes: Uint8Array) => Promise<BlobReference>
readBlob: (reference: BlobReference) => Promise<Uint8Array | null>
}

export type PromptAttachmentsInput = {
Expand All @@ -47,9 +34,102 @@ export type PromptAttachmentsInput = {
addPart: (part: ContentPart) => boolean
readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string
putBlob: (bytes: Uint8Array) => Promise<BlobReference>
readBlob: (reference: BlobReference) => Promise<Uint8Array | null>
}

export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
const [previews, setPreviews] = createStore<Record<string, string | undefined>>({})
const loading = new Set<string>()
const migrating = new Set<string>()
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()
Expand All @@ -65,18 +145,19 @@ 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",
id: uuid(),
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
}

Expand All @@ -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())
}
Expand Down Expand Up @@ -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({
Expand Down
Loading
Loading