634 lines
20 KiB
TypeScript
634 lines
20 KiB
TypeScript
import type { Message, Session } from "@opencode-ai/sdk/v2/client"
|
|
import { showToast } from "@/utils/toast"
|
|
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, startTransition, type Accessor } from "solid-js"
|
|
import { useTabs } from "@/context/tabs"
|
|
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
|
import { useLanguage } from "@/context/language"
|
|
import { useLayout } from "@/context/layout"
|
|
import { useLocal, type ModelSelection } from "@/context/local"
|
|
import { usePermission } from "@/context/permission"
|
|
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
|
|
import { useSDK, type DirectorySDK } from "@/context/sdk"
|
|
import { useSync, type DirectorySync } from "@/context/sync"
|
|
import { Identifier } from "@/utils/id"
|
|
import { Worktree as WorktreeState } from "@/utils/worktree"
|
|
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"
|
|
import { normalizeSessionInfo } from "@/utils/session"
|
|
import { Event } from "@opencode-ai/schema/event"
|
|
|
|
type PendingPrompt = {
|
|
abort: AbortController
|
|
cleanup: VoidFunction
|
|
}
|
|
|
|
const pending = new Map<string, PendingPrompt>()
|
|
|
|
export type FollowupDraft = {
|
|
sessionID: string
|
|
sessionDirectory: string
|
|
prompt: Prompt
|
|
context: (ContextItem & { key: string })[]
|
|
agent: string
|
|
model: { providerID: string; modelID: string }
|
|
variant?: string
|
|
}
|
|
|
|
type FollowupSendInput = {
|
|
api: DirectorySDK["api"]["session"]
|
|
serverSync: ServerSync
|
|
sync: DirectorySync
|
|
draft: FollowupDraft
|
|
messageID?: string
|
|
optimisticBusy?: boolean
|
|
before?: () => Promise<boolean> | boolean
|
|
}
|
|
|
|
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")
|
|
|
|
export async function sendFollowupDraft(input: FollowupSendInput) {
|
|
const text = draftText(input.draft.prompt)
|
|
const images = draftImages(input.draft.prompt)
|
|
const setBusy = () => {
|
|
if (!input.optimisticBusy) return
|
|
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "busy" })
|
|
}
|
|
|
|
const setIdle = () => {
|
|
if (!input.optimisticBusy) return
|
|
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" })
|
|
}
|
|
|
|
const wait = async () => {
|
|
const ok = await input.before?.()
|
|
if (ok === false) return false
|
|
return true
|
|
}
|
|
|
|
const [head, ...tail] = text.split(" ")
|
|
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
|
|
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
|
|
setBusy()
|
|
try {
|
|
if (!(await wait())) {
|
|
setIdle()
|
|
return false
|
|
}
|
|
|
|
const messageID = Identifier.ascending("message")
|
|
await input.api.command({
|
|
sessionID: input.draft.sessionID,
|
|
id: messageID,
|
|
command: cmd,
|
|
arguments: tail.join(" "),
|
|
agent: input.draft.agent,
|
|
model: {
|
|
id: input.draft.model.modelID,
|
|
providerID: input.draft.model.providerID,
|
|
variant: input.draft.variant,
|
|
},
|
|
files: images.map((attachment) => ({
|
|
uri: attachment.dataUrl,
|
|
name: attachment.filename,
|
|
})),
|
|
})
|
|
return true
|
|
} catch (err) {
|
|
setIdle()
|
|
throw err
|
|
}
|
|
}
|
|
|
|
const messageID = input.messageID ?? Identifier.ascending("message")
|
|
const { requestParts, optimisticParts } = buildRequestParts({
|
|
prompt: input.draft.prompt,
|
|
context: input.draft.context,
|
|
images,
|
|
text,
|
|
sessionID: input.draft.sessionID,
|
|
messageID,
|
|
sessionDirectory: input.draft.sessionDirectory,
|
|
})
|
|
|
|
const message: Message = {
|
|
id: messageID,
|
|
sessionID: input.draft.sessionID,
|
|
role: "user",
|
|
time: { created: Date.now() },
|
|
agent: input.draft.agent,
|
|
model: { ...input.draft.model, variant: input.draft.variant },
|
|
}
|
|
|
|
const add = () =>
|
|
input.sync.session.optimistic.add({
|
|
directory: input.draft.sessionDirectory,
|
|
sessionID: input.draft.sessionID,
|
|
message,
|
|
parts: optimisticParts,
|
|
})
|
|
|
|
const remove = () =>
|
|
input.sync.session.optimistic.remove({
|
|
directory: input.draft.sessionDirectory,
|
|
sessionID: input.draft.sessionID,
|
|
messageID,
|
|
})
|
|
|
|
batch(() => {
|
|
setBusy()
|
|
add()
|
|
})
|
|
|
|
try {
|
|
if (!(await wait())) {
|
|
batch(() => {
|
|
setIdle()
|
|
remove()
|
|
})
|
|
return false
|
|
}
|
|
|
|
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) => {
|
|
if (part.type !== "file") return []
|
|
const text = part.source?.text
|
|
return [
|
|
{
|
|
uri: part.url,
|
|
name: part.filename,
|
|
mention: text ? { start: text.start, end: text.end, text: text.value } : undefined,
|
|
},
|
|
]
|
|
}),
|
|
agents: requestParts.flatMap((part) =>
|
|
part.type === "agent"
|
|
? [
|
|
{
|
|
name: part.name,
|
|
mention: part.source
|
|
? { start: part.source.start, end: part.source.end, text: part.source.value }
|
|
: undefined,
|
|
},
|
|
]
|
|
: [],
|
|
),
|
|
})
|
|
return true
|
|
} catch (err) {
|
|
batch(() => {
|
|
setIdle()
|
|
remove()
|
|
})
|
|
throw err
|
|
}
|
|
}
|
|
|
|
type PromptSubmitInput = {
|
|
prompt: ReturnType<typeof usePrompt>
|
|
info: Accessor<{ id: string } | undefined>
|
|
imageAttachments: Accessor<ImageAttachmentPart[]>
|
|
commentCount: Accessor<number>
|
|
autoAccept: Accessor<boolean>
|
|
mode: Accessor<"normal" | "shell">
|
|
working: Accessor<boolean>
|
|
editor: () => HTMLDivElement | undefined
|
|
queueScroll: () => void
|
|
promptLength: (prompt: Prompt) => number
|
|
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
|
|
resetHistoryNavigation: () => void
|
|
setMode: (mode: "normal" | "shell") => void
|
|
setPopover: (popover: "at" | "slash" | null) => void
|
|
newSessionWorktree?: Accessor<string | undefined>
|
|
onNewSessionWorktreeReset?: () => void
|
|
shouldQueue?: Accessor<boolean>
|
|
onQueue?: (draft: FollowupDraft) => void
|
|
onAbort?: () => void
|
|
onSubmit?: () => void
|
|
model?: ModelSelection
|
|
}
|
|
|
|
export function createPromptSubmit(input: PromptSubmitInput) {
|
|
const navigate = useNavigate()
|
|
const sdk = useSDK()
|
|
const sync = useSync()
|
|
const serverSync = useServerSync()
|
|
const local = useLocal()
|
|
const permission = usePermission()
|
|
const prompt = input.prompt
|
|
const layout = useLayout()
|
|
const language = useLanguage()
|
|
const params = useParams()
|
|
const [search] = useSearchParams<{ draftId?: string }>()
|
|
const tabs = useTabs()
|
|
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
|
|
|
const errorMessage = (err: unknown) => {
|
|
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
|
|
if (err && typeof err === "object" && "data" in err) {
|
|
const data = (err as { data?: { message?: string } }).data
|
|
if (data?.message) return data.message
|
|
}
|
|
if (err instanceof Error) return err.message
|
|
return language.t("common.requestFailed")
|
|
}
|
|
|
|
const abort = async () => {
|
|
const sessionID = params.id
|
|
if (!sessionID) return Promise.resolve()
|
|
|
|
serverSync().session.set("todo", sessionID, [])
|
|
|
|
input.onAbort?.()
|
|
|
|
const key = pendingKey(sessionID)
|
|
const queued = pending.get(key)
|
|
if (queued) {
|
|
queued.abort.abort()
|
|
queued.cleanup()
|
|
pending.delete(key)
|
|
return Promise.resolve()
|
|
}
|
|
return sdk()
|
|
.api.session.interrupt({ sessionID })
|
|
.catch(() => {})
|
|
}
|
|
|
|
const restoreCommentItems = (
|
|
target: ReturnType<ReturnType<typeof usePrompt>["capture"]>,
|
|
items: (ContextItem & { key: string })[],
|
|
) => {
|
|
for (const item of items) {
|
|
target.context.add({
|
|
type: "file",
|
|
path: item.path,
|
|
selection: item.selection,
|
|
comment: item.comment,
|
|
commentID: item.commentID,
|
|
commentOrigin: item.commentOrigin,
|
|
preview: item.preview,
|
|
})
|
|
}
|
|
}
|
|
|
|
const clearContext = (target: ReturnType<ReturnType<typeof usePrompt>["capture"]>) => {
|
|
for (const item of target.context.items()) {
|
|
target.context.remove(item.key)
|
|
}
|
|
}
|
|
|
|
const seed = (dir: string, info: Session) => {
|
|
serverSync().session.remember(info)
|
|
const [, setStore] = serverSync().child(dir)
|
|
setStore("session", (list: Session[]) => {
|
|
const result = Binary.search(list, info.id, (item) => item.id)
|
|
const next = [...list]
|
|
if (result.found) {
|
|
next[result.index] = info
|
|
return next
|
|
}
|
|
next.splice(result.index, 0, info)
|
|
return next
|
|
})
|
|
}
|
|
|
|
const handleSubmit = async (event: Event) => {
|
|
event.preventDefault()
|
|
|
|
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()
|
|
|
|
if (text.trim().length === 0 && images.length === 0 && input.commentCount() === 0) {
|
|
if (input.working()) void abort()
|
|
return
|
|
}
|
|
|
|
const modelSelection = input.model ?? local.model
|
|
const currentModel = modelSelection.current()
|
|
const currentAgent = local.agent.current()
|
|
const variant = modelSelection.variant.current()
|
|
if (!currentModel || !currentAgent) {
|
|
showToast({
|
|
title: language.t("prompt.toast.modelAgentRequired.title"),
|
|
description: language.t("prompt.toast.modelAgentRequired.description"),
|
|
})
|
|
return
|
|
}
|
|
|
|
input.addToHistory(currentPrompt, mode)
|
|
input.resetHistoryNavigation()
|
|
|
|
const projectDirectory = sdk().directory
|
|
const permissionState = permission.currentServerState()
|
|
const isNewSession = !params.id
|
|
const shouldAutoAccept = isNewSession && input.autoAccept()
|
|
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
|
|
|
let sessionDirectory = projectDirectory
|
|
let client = sdk().client
|
|
|
|
if (isNewSession) {
|
|
if (worktreeSelection === "create") {
|
|
const createdWorktree = await client.worktree
|
|
.create({ directory: projectDirectory })
|
|
.then((x) => x.data)
|
|
.catch((err) => {
|
|
showToast({
|
|
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
|
description: errorMessage(err),
|
|
})
|
|
return undefined
|
|
})
|
|
|
|
if (!createdWorktree?.directory) {
|
|
showToast({
|
|
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
|
description: language.t("common.requestFailed"),
|
|
})
|
|
return
|
|
}
|
|
WorktreeState.pending(sdk().scope, createdWorktree.directory)
|
|
sessionDirectory = createdWorktree.directory
|
|
}
|
|
|
|
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
|
|
sessionDirectory = worktreeSelection
|
|
}
|
|
|
|
if (sessionDirectory !== projectDirectory) {
|
|
client = sdk().createClient({
|
|
directory: sessionDirectory,
|
|
throwOnError: true,
|
|
})
|
|
serverSync().child(sessionDirectory)
|
|
}
|
|
|
|
input.onNewSessionWorktreeReset?.()
|
|
}
|
|
|
|
let session = input.info()
|
|
if (!session && isNewSession) {
|
|
const created = await sdk()
|
|
.api.session.create({
|
|
agent: currentAgent.name,
|
|
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
|
location: { directory: sessionDirectory },
|
|
})
|
|
.then(normalizeSessionInfo)
|
|
.catch((err) => {
|
|
showToast({
|
|
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
|
description: errorMessage(err),
|
|
})
|
|
return undefined
|
|
})
|
|
if (created) {
|
|
seed(sessionDirectory, created)
|
|
session = created
|
|
await startTransition(() => {
|
|
if (!session) return
|
|
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
|
local.session.promote(sessionDirectory, session.id, {
|
|
agent: currentAgent.name,
|
|
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
|
variant: variant ?? null,
|
|
})
|
|
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
|
const draftID = search.draftId
|
|
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
|
|
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
|
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
|
|
})
|
|
}
|
|
}
|
|
if (!session) {
|
|
showToast({
|
|
title: language.t("prompt.toast.promptSendFailed.title"),
|
|
description: language.t("prompt.toast.promptSendFailed.description"),
|
|
})
|
|
return
|
|
}
|
|
|
|
const model = {
|
|
modelID: currentModel.id,
|
|
providerID: currentModel.provider.id,
|
|
}
|
|
const agent = currentAgent.name
|
|
const draft: FollowupDraft = {
|
|
sessionID: session.id,
|
|
sessionDirectory,
|
|
prompt: currentPrompt,
|
|
context,
|
|
agent,
|
|
model,
|
|
variant,
|
|
}
|
|
|
|
const clearInput = () => {
|
|
submission.clear()
|
|
input.setMode("normal")
|
|
input.setPopover(null)
|
|
}
|
|
|
|
const restoreInput = () => {
|
|
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(() => {
|
|
const editor = input.editor()
|
|
if (!editor) return
|
|
editor.focus()
|
|
setCursorPosition(editor, input.promptLength(currentPrompt))
|
|
input.queueScroll()
|
|
})
|
|
return true
|
|
}
|
|
|
|
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
|
input.onQueue?.(draft)
|
|
clearContext(submission.target())
|
|
clearInput()
|
|
return
|
|
}
|
|
|
|
input.onSubmit?.()
|
|
|
|
if (mode === "shell") {
|
|
clearInput()
|
|
const eventID = Event.ID.create()
|
|
sdk()
|
|
.api.session.shell({
|
|
sessionID: session.id,
|
|
id: eventID,
|
|
command: text,
|
|
agent,
|
|
model,
|
|
})
|
|
.catch((err) => {
|
|
showToast({
|
|
title: language.t("prompt.toast.shellSendFailed.title"),
|
|
description: errorMessage(err),
|
|
})
|
|
restoreInput()
|
|
})
|
|
return
|
|
}
|
|
|
|
if (text.startsWith("/")) {
|
|
const [cmdName, ...args] = text.split(" ")
|
|
const commandName = cmdName.slice(1)
|
|
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
|
if (customCommand) {
|
|
clearInput()
|
|
const messageID = Identifier.ascending("message")
|
|
serverSync().session.set("session_status", session.id, { type: "busy" })
|
|
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,
|
|
})),
|
|
})
|
|
.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
|
|
}
|
|
}
|
|
|
|
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
|
const messageID = Identifier.ascending("message")
|
|
|
|
const removeOptimisticMessage = () => {
|
|
sync().session.optimistic.remove({
|
|
directory: sessionDirectory,
|
|
sessionID: session.id,
|
|
messageID,
|
|
})
|
|
}
|
|
|
|
for (const item of commentItems) submission.target().context.remove(item.key)
|
|
clearInput()
|
|
|
|
const waitForWorktree = async () => {
|
|
const worktree = WorktreeState.get(sdk().scope, sessionDirectory)
|
|
if (!worktree || worktree.status !== "pending") return true
|
|
|
|
if (sessionDirectory === projectDirectory) {
|
|
sync().set("session_status", session.id, { type: "busy" })
|
|
}
|
|
|
|
const controller = new AbortController()
|
|
const cleanup = () => {
|
|
if (sessionDirectory === projectDirectory) {
|
|
sync().set("session_status", session.id, { type: "idle" })
|
|
}
|
|
removeOptimisticMessage()
|
|
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
|
}
|
|
|
|
pending.set(pendingKey(session.id), { abort: controller, cleanup })
|
|
|
|
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
|
if (controller.signal.aborted) {
|
|
resolve({ status: "failed", message: "aborted" })
|
|
return
|
|
}
|
|
controller.signal.addEventListener(
|
|
"abort",
|
|
() => {
|
|
resolve({ status: "failed", message: "aborted" })
|
|
},
|
|
{ once: true },
|
|
)
|
|
})
|
|
|
|
const timeoutMs = 5 * 60 * 1000
|
|
const timer = { id: undefined as number | undefined }
|
|
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
|
timer.id = window.setTimeout(() => {
|
|
resolve({
|
|
status: "failed",
|
|
message: language.t("workspace.error.stillPreparing"),
|
|
})
|
|
}, timeoutMs)
|
|
})
|
|
|
|
const result = await Promise.race([
|
|
WorktreeState.wait(sdk().scope, sessionDirectory),
|
|
abortWait,
|
|
timeout,
|
|
]).finally(() => {
|
|
if (timer.id === undefined) return
|
|
clearTimeout(timer.id)
|
|
})
|
|
pending.delete(pendingKey(session.id))
|
|
if (controller.signal.aborted) return false
|
|
if (result.status === "failed") throw new Error(result.message)
|
|
return true
|
|
}
|
|
|
|
void sendFollowupDraft({
|
|
api: sdk().api.session,
|
|
sync: sync(),
|
|
serverSync: serverSync(),
|
|
draft,
|
|
messageID,
|
|
optimisticBusy: sessionDirectory === projectDirectory,
|
|
before: waitForWorktree,
|
|
}).catch((err) => {
|
|
pending.delete(pendingKey(session.id))
|
|
if (sessionDirectory === projectDirectory) {
|
|
sync().set("session_status", session.id, { type: "idle" })
|
|
}
|
|
showToast({
|
|
title: language.t("prompt.toast.promptSendFailed.title"),
|
|
description: errorMessage(err),
|
|
})
|
|
removeOptimisticMessage()
|
|
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
|
})
|
|
}
|
|
|
|
return {
|
|
abort,
|
|
handleSubmit,
|
|
}
|
|
}
|