fix(app): separate provider lifetimes and reactive ownership (#33739)
This commit is contained in:
parent
dc569b5a5f
commit
cfd75d62fe
37 changed files with 838 additions and 274 deletions
|
|
@ -25,6 +25,19 @@ function dataUrl(file: File, mime: string) {
|
|||
})
|
||||
}
|
||||
|
||||
type PromptTarget = Pick<ReturnType<ReturnType<typeof usePrompt>["capture"]>, "current" | "cursor" | "set">
|
||||
type AttachmentTarget = { prompt: PromptTarget; cursor: number | undefined }
|
||||
|
||||
type PromptAttachmentsCoreInput = {
|
||||
capture: () => PromptTarget
|
||||
editor: () => HTMLDivElement | undefined
|
||||
focusEditor?: () => void
|
||||
addPart?: (part: ContentPart) => boolean
|
||||
warn?: () => void
|
||||
readClipboardImage?: () => Promise<File | null>
|
||||
getPathForFile?: (file: File) => string
|
||||
}
|
||||
|
||||
type PromptAttachmentsInput = {
|
||||
prompt: ReturnType<typeof usePrompt>
|
||||
editor: () => HTMLDivElement | undefined
|
||||
|
|
@ -36,27 +49,22 @@ type PromptAttachmentsInput = {
|
|||
getPathForFile?: (file: File) => string
|
||||
}
|
||||
|
||||
export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
const prompt = input.prompt
|
||||
const language = useLanguage()
|
||||
|
||||
const warn = () => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.pasteUnsupported.title"),
|
||||
description: language.t("prompt.toast.pasteUnsupported.description"),
|
||||
})
|
||||
export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
|
||||
const capture = (): AttachmentTarget | undefined => {
|
||||
const prompt = input.capture()
|
||||
const editor = input.editor()
|
||||
if (!editor) return
|
||||
return { prompt, cursor: prompt.cursor() ?? getCursorPosition(editor) }
|
||||
}
|
||||
|
||||
const add = async (file: File, toast = true) => {
|
||||
const add = async (file: File, toast = true, target = capture()) => {
|
||||
if (!target) return false
|
||||
const mime = await attachmentMime(file)
|
||||
if (!mime) {
|
||||
if (toast) warn()
|
||||
if (toast) input.warn?.()
|
||||
return false
|
||||
}
|
||||
|
||||
const editor = input.editor()
|
||||
if (!editor) return false
|
||||
|
||||
const url = await dataUrl(file, mime)
|
||||
if (!url) return false
|
||||
|
||||
|
|
@ -68,34 +76,42 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||
mime,
|
||||
dataUrl: url,
|
||||
}
|
||||
const cursor = prompt.cursor() ?? getCursorPosition(editor)
|
||||
prompt.set([...prompt.current(), attachment], cursor)
|
||||
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
|
||||
return true
|
||||
}
|
||||
|
||||
const addAttachment = (file: File) => add(file)
|
||||
|
||||
const addAttachments = async (files: File[], toast = true) => {
|
||||
const addAttachments = async (files: File[], toast = true, target = capture()) => {
|
||||
let found = false
|
||||
|
||||
for (const file of files) {
|
||||
const ok = await add(file, false)
|
||||
const ok = await add(file, false, target)
|
||||
if (ok) found = true
|
||||
}
|
||||
|
||||
if (!found && files.length > 0 && toast) warn()
|
||||
if (!found && files.length > 0 && toast) input.warn?.()
|
||||
return found
|
||||
}
|
||||
|
||||
const addClipboardAttachment = async (pending: Promise<File | null>, target = capture()) => {
|
||||
const file = await pending
|
||||
if (!file) return false
|
||||
return add(file, true, target)
|
||||
}
|
||||
|
||||
const removeAttachment = (id: string) => {
|
||||
const current = prompt.current()
|
||||
const target = input.capture()
|
||||
const current = target.current()
|
||||
const next = current.filter((part) => part.type !== "image" || part.id !== id)
|
||||
prompt.set(next, prompt.cursor())
|
||||
target.set(next, target.cursor())
|
||||
}
|
||||
|
||||
const handlePaste = async (event: ClipboardEvent) => {
|
||||
const clipboardData = event.clipboardData
|
||||
if (!clipboardData) return
|
||||
const target = capture()
|
||||
if (!target) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
|
@ -107,7 +123,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||
})
|
||||
|
||||
if (files.length > 0) {
|
||||
await addAttachments(files)
|
||||
await addAttachments(files, true, target)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -115,11 +131,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||
|
||||
// Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images
|
||||
if (input.readClipboardImage && !plainText) {
|
||||
const file = await input.readClipboardImage()
|
||||
if (file) {
|
||||
await addAttachment(file)
|
||||
return
|
||||
}
|
||||
if (await addClipboardAttachment(input.readClipboardImage(), target)) return
|
||||
}
|
||||
|
||||
if (!plainText) return
|
||||
|
|
@ -127,9 +139,9 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||
const text = normalizePaste(plainText)
|
||||
|
||||
const put = () => {
|
||||
if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true
|
||||
input.focusEditor()
|
||||
return input.addPart({ type: "text", content: text, start: 0, end: 0 })
|
||||
if (input.addPart?.({ type: "text", content: text, start: 0, end: 0 })) return true
|
||||
input.focusEditor?.()
|
||||
return input.addPart?.({ type: "text", content: text, start: 0, end: 0 }) ?? false
|
||||
}
|
||||
|
||||
if (pasteMode(text) === "manual") {
|
||||
|
|
@ -143,6 +155,28 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||
put()
|
||||
}
|
||||
|
||||
return {
|
||||
addAttachment,
|
||||
addAttachments,
|
||||
addClipboardAttachment,
|
||||
removeAttachment,
|
||||
handlePaste,
|
||||
}
|
||||
}
|
||||
|
||||
export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||
const language = useLanguage()
|
||||
const attachments = createPromptAttachmentsCore({
|
||||
...input,
|
||||
capture: input.prompt.capture,
|
||||
warn: () => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.pasteUnsupported.title"),
|
||||
description: language.t("prompt.toast.pasteUnsupported.description"),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const handleGlobalDragOver = (event: DragEvent) => {
|
||||
if (input.isDialogActive()) return
|
||||
|
||||
|
|
@ -181,7 +215,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||
const dropped = event.dataTransfer?.files
|
||||
if (!dropped) return
|
||||
|
||||
await addAttachments(Array.from(dropped))
|
||||
await attachments.addAttachments(Array.from(dropped))
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
|
|
@ -190,10 +224,5 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||
makeEventListener(document, "drop", handleGlobalDrop)
|
||||
})
|
||||
|
||||
return {
|
||||
addAttachment,
|
||||
addAttachments,
|
||||
removeAttachment,
|
||||
handlePaste,
|
||||
}
|
||||
return attachments
|
||||
}
|
||||
|
|
|
|||
31
packages/app/src/components/prompt-input/submission-state.ts
Normal file
31
packages/app/src/components/prompt-input/submission-state.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { type ContextItem, type Prompt, type usePrompt } from "@/context/prompt"
|
||||
|
||||
type PromptTarget = ReturnType<ReturnType<typeof usePrompt>["capture"]>
|
||||
|
||||
export function createPromptSubmissionState(input: {
|
||||
target: PromptTarget
|
||||
prompt: Prompt
|
||||
context: (ContextItem & { key: string })[]
|
||||
}) {
|
||||
let target = input.target
|
||||
let cleared: Prompt | undefined
|
||||
|
||||
return {
|
||||
prompt: input.prompt,
|
||||
context: input.context,
|
||||
target: () => target,
|
||||
clear() {
|
||||
target.reset()
|
||||
cleared = target.current()
|
||||
},
|
||||
retarget(next: PromptTarget) {
|
||||
input.context.forEach(next.context.add)
|
||||
target = next
|
||||
},
|
||||
current: (value: PromptTarget) => target === value,
|
||||
restore() {
|
||||
if (cleared !== undefined && target.current() !== cleared) return
|
||||
return { target, prompt: input.prompt, context: input.context }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -41,6 +41,7 @@ const prompt = {
|
|||
replaceComments: () => undefined,
|
||||
items: () => [],
|
||||
},
|
||||
capture: () => prompt,
|
||||
}
|
||||
|
||||
const clientFor = (directory: string) => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
|
|||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { batch, type Accessor } from "solid-js"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||
|
|
@ -21,6 +20,7 @@ import { buildRequestParts } from "./build-request-parts"
|
|||
import { setCursorPosition } from "./editor-dom"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { ScopedKey } from "@/utils/server-scope"
|
||||
import { createPromptSubmissionState } from "./submission-state"
|
||||
|
||||
type PendingPrompt = {
|
||||
abort: AbortController
|
||||
|
|
@ -194,15 +194,6 @@ type PromptSubmitInput = {
|
|||
onSubmit?: () => void
|
||||
}
|
||||
|
||||
type CommentItem = {
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
comment?: string
|
||||
commentID?: string
|
||||
commentOrigin?: "review" | "file"
|
||||
preview?: string
|
||||
}
|
||||
|
||||
export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
|
|
@ -251,9 +242,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
.catch(() => {})
|
||||
}
|
||||
|
||||
const restoreCommentItems = (items: CommentItem[]) => {
|
||||
const restoreCommentItems = (
|
||||
target: ReturnType<ReturnType<typeof usePrompt>["capture"]>,
|
||||
items: (ContextItem & { key: string })[],
|
||||
) => {
|
||||
for (const item of items) {
|
||||
prompt.context.add({
|
||||
target.context.add({
|
||||
type: "file",
|
||||
path: item.path,
|
||||
selection: item.selection,
|
||||
|
|
@ -265,15 +259,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
}
|
||||
}
|
||||
|
||||
const removeCommentItems = (items: { key: string }[]) => {
|
||||
for (const item of items) {
|
||||
prompt.context.remove(item.key)
|
||||
}
|
||||
}
|
||||
|
||||
const clearContext = () => {
|
||||
for (const item of prompt.context.items()) {
|
||||
prompt.context.remove(item.key)
|
||||
const clearContext = (target: ReturnType<ReturnType<typeof usePrompt>["capture"]>) => {
|
||||
for (const item of target.context.items()) {
|
||||
target.context.remove(item.key)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -295,7 +283,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
const handleSubmit = async (event: Event) => {
|
||||
event.preventDefault()
|
||||
|
||||
const currentPrompt = prompt.current()
|
||||
const target = prompt.capture()
|
||||
const submission = createPromptSubmissionState({
|
||||
target,
|
||||
prompt: target.current(),
|
||||
context: target.context.items().slice(),
|
||||
})
|
||||
const currentPrompt = submission.prompt
|
||||
const context = submission.context
|
||||
const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||
const images = input.imageAttachments().slice()
|
||||
const mode = input.mode()
|
||||
|
|
@ -387,6 +382,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
const draftID = search.draftId
|
||||
if (draftID) tabs.promoteDraft(draftID, { server: server.key, sessionId: session.id })
|
||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
|
||||
}
|
||||
}
|
||||
if (!session) {
|
||||
|
|
@ -402,7 +398,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
providerID: currentModel.provider.id,
|
||||
}
|
||||
const agent = currentAgent.name
|
||||
const context = prompt.context.items().slice()
|
||||
const draft: FollowupDraft = {
|
||||
sessionID: session.id,
|
||||
sessionDirectory,
|
||||
|
|
@ -414,13 +409,16 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
}
|
||||
|
||||
const clearInput = () => {
|
||||
prompt.reset()
|
||||
submission.clear()
|
||||
input.setMode("normal")
|
||||
input.setPopover(null)
|
||||
}
|
||||
|
||||
const restoreInput = () => {
|
||||
prompt.set(currentPrompt, input.promptLength(currentPrompt))
|
||||
const restored = submission.restore()
|
||||
if (!restored) return false
|
||||
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
|
||||
if (!submission.current(prompt.capture())) return true
|
||||
input.setMode(mode)
|
||||
input.setPopover(null)
|
||||
requestAnimationFrame(() => {
|
||||
|
|
@ -430,11 +428,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
setCursorPosition(editor, input.promptLength(currentPrompt))
|
||||
input.queueScroll()
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
||||
input.onQueue?.(draft)
|
||||
clearContext()
|
||||
clearContext(submission.target())
|
||||
clearInput()
|
||||
return
|
||||
}
|
||||
|
|
@ -504,7 +503,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
})
|
||||
}
|
||||
|
||||
removeCommentItems(commentItems)
|
||||
for (const item of commentItems) submission.target().context.remove(item.key)
|
||||
clearInput()
|
||||
|
||||
const waitForWorktree = async () => {
|
||||
|
|
@ -521,8 +520,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
sync().set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
removeOptimisticMessage()
|
||||
restoreCommentItems(commentItems)
|
||||
restoreInput()
|
||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||
}
|
||||
|
||||
pending.set(pendingKey(session.id), { abort: controller, cleanup })
|
||||
|
|
@ -584,8 +582,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||
description: errorMessage(err),
|
||||
})
|
||||
removeOptimisticMessage()
|
||||
restoreCommentItems(commentItems)
|
||||
restoreInput()
|
||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
43
packages/app/src/components/prompt-input/transient-state.ts
Normal file
43
packages/app/src/components/prompt-input/transient-state.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { createComputed, on, type Accessor } from "solid-js"
|
||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
import type { PromptHistoryEntry } from "./history"
|
||||
|
||||
export type PromptInputTransientState = {
|
||||
popover: "at" | "slash" | null
|
||||
historyIndex: number
|
||||
savedPrompt: PromptHistoryEntry | null
|
||||
placeholder: number
|
||||
draggingType: "image" | "@mention" | null
|
||||
mode: "normal" | "shell"
|
||||
applyingHistory: boolean
|
||||
variantOpen: boolean
|
||||
}
|
||||
|
||||
function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
|
||||
setStore({
|
||||
popover: null,
|
||||
historyIndex: -1,
|
||||
savedPrompt: null,
|
||||
draggingType: null,
|
||||
mode: "normal",
|
||||
applyingHistory: false,
|
||||
variantOpen: false,
|
||||
})
|
||||
}
|
||||
|
||||
export function createPromptInputTransientState(identity: Accessor<unknown>, placeholder: number) {
|
||||
const [store, setStore] = createStore<PromptInputTransientState>({
|
||||
popover: null,
|
||||
historyIndex: -1,
|
||||
savedPrompt: null,
|
||||
placeholder,
|
||||
draggingType: null,
|
||||
mode: "normal",
|
||||
applyingHistory: false,
|
||||
variantOpen: false,
|
||||
})
|
||||
|
||||
createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true }))
|
||||
|
||||
return [store, setStore] as const
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue