diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index da45c351ec7..af1203ccc34 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -26,6 +26,7 @@ import type { IconName } from "@opencode-ai/ui/icons/provider" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { useDialog } from "@opencode-ai/ui/context/dialog" import { ModelSelectorPopover } from "@/components/dialog-select-model" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" @@ -46,6 +47,15 @@ import { PromptImageAttachments } from "./prompt-input/image-attachments" import { PromptDragOverlay } from "./prompt-input/drag-overlay" import { promptPlaceholder } from "./prompt-input/placeholder" import { ImagePreview } from "@opencode-ai/ui/image-preview" +import { suppressAbortedError } from "@/utils/session-abort" + +type QueuedPrompt = { + sessionID: string + messageID: string + prompt: Prompt + mode: "normal" | "shell" + preview: string +} interface PromptInputProps { class?: string @@ -105,6 +115,7 @@ export const PromptInput: Component = (props) => { let slashPopoverRef!: HTMLDivElement const mirror = { input: false } + const immediate = { sendNow: false } const scrollCursorIntoView = () => { const container = scrollRef @@ -219,6 +230,15 @@ export const PromptInput: Component = (props) => { mode: "normal", applyingHistory: false, }) + const [queued, setQueued] = createStore<{ + item: QueuedPrompt | undefined + menu: boolean + busy: boolean + }>({ + item: undefined, + menu: false, + busy: false, + }) const placeholder = createMemo(() => promptPlaceholder({ mode: store.mode, @@ -228,6 +248,17 @@ export const PromptInput: Component = (props) => { }), ) + const previewQueued = (next: Prompt) => + next + .map((part) => { + if (part.type === "file") return `@${part.path}` + if (part.type === "agent") return `@${part.name}` + if (part.type === "image") return `[${part.filename}]` + return part.content + }) + .join("") + .trim() + const MAX_HISTORY = 100 const [history, setHistory] = persisted( Persist.global("prompt-history", ["prompt-history.v1"]), @@ -790,6 +821,116 @@ export const PromptInput: Component = (props) => { newSessionWorktree: props.newSessionWorktree, onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, onSubmit: props.onSubmit, + skipQueue: () => { + if (!immediate.sendNow) return false + immediate.sendNow = false + return true + }, + onQueuedMessage: (item) => { + setQueued({ + item: { + sessionID: item.sessionID, + messageID: item.messageID, + prompt: item.prompt, + mode: item.mode, + preview: previewQueued(item.prompt), + }, + menu: true, + busy: false, + }) + }, + }) + + const clearQueued = () => { + setQueued({ + item: undefined, + menu: false, + busy: false, + }) + } + + const restoreQueued = (item: QueuedPrompt) => { + prompt.set(item.prompt, promptLength(item.prompt)) + setStore("mode", item.mode) + setStore("popover", null) + requestAnimationFrame(() => { + editorRef.focus() + setCursorPosition(editorRef, promptLength(item.prompt)) + queueScroll() + }) + } + + const removeQueued = async (edit: boolean) => { + const item = queued.item + const sessionID = params.id + if (!item || !sessionID || queued.busy) return + + setQueued("busy", true) + const ok = await sdk.client.session + .revert({ sessionID, messageID: item.messageID }) + .then(() => true) + .catch((err) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + return false + }) + + if (!ok) { + setQueued("busy", false) + return + } + + clearQueued() + if (edit) restoreQueued(item) + } + + const sendQueuedNow = async () => { + const item = queued.item + const sessionID = params.id + if (!item || !sessionID || queued.busy) return + + setQueued("busy", true) + const messages = sync.data.message[sessionID] ?? [] + const running = [...messages] + .reverse() + .find((message) => message.role === "assistant" && "parentID" in message && !message.time.completed) + if (running && "parentID" in running) suppressAbortedError(sessionID, running.parentID) + + if (working()) await abort() + + const ok = await sdk.client.session + .revert({ sessionID, messageID: item.messageID }) + .then(() => true) + .catch((err) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + return false + }) + + if (!ok) { + setQueued("busy", false) + return + } + + clearQueued() + restoreQueued(item) + requestAnimationFrame(() => { + immediate.sendNow = true + void handleSubmit(new Event("submit")) + }) + } + + createEffect(() => { + const item = queued.item + if (!item) return + const sessionID = params.id + if (!sessionID || item.sessionID !== sessionID) { + clearQueued() + return + } + const messages = sync.data.message[sessionID] ?? [] + const answered = messages.some((message) => message.role === "assistant" && message.parentID === item.messageID) + if (answered) clearQueued() }) const handleKeyDown = (event: KeyboardEvent) => { @@ -982,6 +1123,51 @@ export const PromptInput: Component = (props) => { onRemove={removeImageAttachment} removeLabel={language.t("prompt.attachment.remove")} /> + +
+
+
+
{language.t("prompt.queue.title")}
+
{queued.item?.preview}
+
+ + + void removeQueued(false)} + aria-label={language.t("prompt.queue.remove")} + /> + + setQueued("menu", open)}> + + + + void removeQueued(true)}> + {language.t("common.edit")} + + + + +
+
+
(scrollRef = el)}>
void onSubmit?: () => void + skipQueue?: () => boolean + onQueuedMessage?: (item: { sessionID: string; messageID: string; prompt: Prompt; mode: "normal" | "shell" }) => void } type CommentItem = { @@ -292,6 +294,17 @@ export function createPromptSubmit(input: PromptSubmitInput) { const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) const messageID = Identifier.ascending("message") + const queuedMessage = input.working() && !input.skipQueue?.() + + if (queuedMessage) { + input.onQueuedMessage?.({ + sessionID: session.id, + messageID, + prompt: currentPrompt, + mode, + }) + } + const { requestParts, optimisticParts } = buildRequestParts({ prompt: currentPrompt, context, diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index f4f49f055be..b74d0feb25e 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -269,6 +269,10 @@ export const dict = { "prompt.attachment.remove": "Remove attachment", "prompt.action.send": "Send", "prompt.action.stop": "Stop", + "prompt.queue.title": "Queued message", + "prompt.queue.sendNow": "Send now", + "prompt.queue.remove": "Remove from queue", + "prompt.queue.menu": "Queued message options", "prompt.toast.pasteUnsupported.title": "Unsupported paste", "prompt.toast.pasteUnsupported.description": "Only images or PDFs can be pasted here.", diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 7678ea6a8d1..99486786f5f 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -58,6 +58,7 @@ import { SessionPromptDock } from "@/pages/session/session-prompt-dock" import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs" import { SessionSidePanel } from "@/pages/session/session-side-panel" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" +import { isAbortedErrorSuppressed } from "@/utils/session-abort" type HandoffSession = { prompt: string @@ -1631,6 +1632,7 @@ export default function Page() { lastUserMessageID={lastUserMessage()?.id} expanded={store.expanded} onToggleExpanded={(id) => setStore("expanded", id, (open: boolean | undefined) => !open)} + suppressAbortedError={(messageID) => isAbortedErrorSuppressed(params.id!, messageID)} /> diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index f536c7061fb..b2ac1fcc247 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -59,6 +59,7 @@ export function MessageTimeline(props: { lastUserMessageID?: string expanded: Record onToggleExpanded: (id: string) => void + suppressAbortedError: (messageID: string) => boolean }) { let touchGesture: number | undefined @@ -328,6 +329,7 @@ export function MessageTimeline(props: { sessionID={props.sessionID} messageID={message.id} lastUserMessageID={props.lastUserMessageID} + suppressAbortedError={props.suppressAbortedError(message.id)} stepsExpanded={props.expanded[message.id] ?? false} onStepsExpandedToggle={() => props.onToggleExpanded(message.id)} classes={{ diff --git a/packages/app/src/utils/session-abort.ts b/packages/app/src/utils/session-abort.ts new file mode 100644 index 00000000000..b5f6ed74025 --- /dev/null +++ b/packages/app/src/utils/session-abort.ts @@ -0,0 +1,14 @@ +const suppressed = new Map>() + +export function suppressAbortedError(sessionID: string, messageID: string) { + const set = suppressed.get(sessionID) + if (set) { + set.add(messageID) + return + } + suppressed.set(sessionID, new Set([messageID])) +} + +export function isAbortedErrorSuppressed(sessionID: string, messageID: string) { + return suppressed.get(sessionID)?.has(messageID) ?? false +} diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index c2e26b9c7b3..8491b9740d6 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -187,6 +187,7 @@ export function SessionTurn( stepsExpanded?: boolean onStepsExpandedToggle?: () => void onUserInteracted?: () => void + suppressAbortedError?: boolean classes?: { root?: string content?: string @@ -289,8 +290,15 @@ export function SessionTurn( const lastAssistantMessage = createMemo(() => assistantMessages().at(-1)) const error = createMemo(() => assistantMessages().find((m) => m.error)?.error) + const visibleError = createMemo(() => { + const next = error() + if (!next) return undefined + if (!props.suppressAbortedError) return next + if (next.name !== "MessageAbortedError") return next + return undefined + }) const errorText = createMemo(() => { - const msg = error()?.data?.message + const msg = visibleError()?.data?.message if (typeof msg === "string") return unwrap(msg) if (msg === undefined || msg === null) return "" return unwrap(String(msg)) @@ -731,7 +739,7 @@ export function SessionTurn( /> )} - + {errorText()} @@ -787,7 +795,7 @@ export function SessionTurn(
- + {errorText()}