import type { Message } from "@opencode-ai/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" import { base64Encode } from "@opencode-ai/util/encode" import { useNavigate, useParams } from "@solidjs/router" import type { Accessor } from "solid-js" import type { FileSelection } from "@/context/file" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useLocal } from "@/context/local" import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" import { useSync } 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" type PendingPrompt = { abort: AbortController cleanup: VoidFunction } const pending = new Map() type PromptSubmitInput = { info: Accessor<{ id: string } | undefined> imageAttachments: Accessor commentCount: Accessor mode: Accessor<"normal" | "shell"> working: Accessor 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 onNewSessionWorktreeReset?: () => void 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() const sync = useSync() const globalSync = useGlobalSync() const local = useLocal() const prompt = usePrompt() const layout = useLayout() const language = useLanguage() const params = useParams() const errorMessage = (err: unknown) => { 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() const queued = pending.get(sessionID) if (queued) { queued.abort.abort() queued.cleanup() pending.delete(sessionID) globalSync.todo.set(sessionID, undefined) return Promise.resolve() } return sdk.client.session .abort({ sessionID, }) .catch(() => {}) .finally(() => { globalSync.todo.set(sessionID, undefined) }) } const restoreCommentItems = (items: CommentItem[]) => { for (const item of items) { prompt.context.add({ type: "file", path: item.path, selection: item.selection, comment: item.comment, commentID: item.commentID, commentOrigin: item.commentOrigin, preview: item.preview, }) } } const removeCommentItems = (items: { key: string }[]) => { for (const item of items) { prompt.context.remove(item.key) } } const handleSubmit = async (event: Event) => { event.preventDefault() const currentPrompt = prompt.current() 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()) abort() return } const currentModel = local.model.current() const currentAgent = local.agent.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 isNewSession = !params.id 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(createdWorktree.directory) sessionDirectory = createdWorktree.directory } if (worktreeSelection !== "main" && worktreeSelection !== "create") { sessionDirectory = worktreeSelection } if (sessionDirectory !== projectDirectory) { client = sdk.createClient({ directory: sessionDirectory, throwOnError: true, }) globalSync.child(sessionDirectory) } input.onNewSessionWorktreeReset?.() } let session = input.info() if (!session && isNewSession) { session = await client.session .create() .then((x) => x.data ?? undefined) .catch((err) => { showToast({ title: language.t("prompt.toast.sessionCreateFailed.title"), description: errorMessage(err), }) return undefined }) if (session) { layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) } } if (!session) { showToast({ title: language.t("prompt.toast.promptSendFailed.title"), description: language.t("prompt.toast.promptSendFailed.description"), }) return } input.onSubmit?.() const model = { modelID: currentModel.id, providerID: currentModel.provider.id, } const agent = currentAgent.name const variant = local.model.variant.current() const clearInput = () => { prompt.reset() input.setMode("normal") input.setPopover(null) } const restoreInput = () => { prompt.set(currentPrompt, input.promptLength(currentPrompt)) input.setMode(mode) input.setPopover(null) requestAnimationFrame(() => { const editor = input.editor() if (!editor) return editor.focus() setCursorPosition(editor, input.promptLength(currentPrompt)) input.queueScroll() }) } if (mode === "shell") { clearInput() client.session .shell({ sessionID: session.id, agent, model, command: text, }) .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() client.session .command({ sessionID: session.id, command: commandName, arguments: args.join(" "), agent, model: `${model.providerID}/${model.modelID}`, variant, parts: images.map((attachment) => ({ id: Identifier.ascending("part"), type: "file" as const, mime: attachment.mime, url: attachment.dataUrl, filename: attachment.filename, })), }) .catch((err) => { showToast({ title: language.t("prompt.toast.commandSendFailed.title"), description: errorMessage(err), }) restoreInput() }) return } } const context = prompt.context.items().slice() const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) const messageID = Identifier.ascending("message") const { requestParts, optimisticParts } = buildRequestParts({ prompt: currentPrompt, context, images, text, sessionID: session.id, messageID, sessionDirectory, }) const optimisticMessage: Message = { id: messageID, sessionID: session.id, role: "user", time: { created: Date.now() }, agent, model, } const addOptimisticMessage = () => sync.session.optimistic.add({ directory: sessionDirectory, sessionID: session.id, message: optimisticMessage, parts: optimisticParts, }) const removeOptimisticMessage = () => sync.session.optimistic.remove({ directory: sessionDirectory, sessionID: session.id, messageID, }) removeCommentItems(commentItems) clearInput() addOptimisticMessage() const waitForWorktree = async () => { const worktree = WorktreeState.get(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() restoreCommentItems(commentItems) restoreInput() } pending.set(session.id, { abort: controller, cleanup }) const abortWait = new Promise>>((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>>((resolve) => { timer.id = window.setTimeout(() => { resolve({ status: "failed", message: language.t("workspace.error.stillPreparing"), }) }, timeoutMs) }) const result = await Promise.race([WorktreeState.wait(sessionDirectory), abortWait, timeout]).finally(() => { if (timer.id === undefined) return clearTimeout(timer.id) }) pending.delete(session.id) if (controller.signal.aborted) return false if (result.status === "failed") throw new Error(result.message) return true } const send = async () => { const ok = await waitForWorktree() if (!ok) return await client.session.promptAsync({ sessionID: session.id, agent, model, messageID, parts: requestParts, variant, }) } void send().catch((err) => { pending.delete(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() restoreCommentItems(commentItems) restoreInput() }) } return { abort, handleSubmit, } }