Skip to content
Open
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
186 changes: 186 additions & 0 deletions packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -105,6 +115,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let slashPopoverRef!: HTMLDivElement

const mirror = { input: false }
const immediate = { sendNow: false }

const scrollCursorIntoView = () => {
const container = scrollRef
Expand Down Expand Up @@ -219,6 +230,15 @@ export const PromptInput: Component<PromptInputProps> = (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,
Expand All @@ -228,6 +248,17 @@ export const PromptInput: Component<PromptInputProps> = (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"]),
Expand Down Expand Up @@ -790,6 +821,116 @@ export const PromptInput: Component<PromptInputProps> = (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) => {
Expand Down Expand Up @@ -982,6 +1123,51 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onRemove={removeImageAttachment}
removeLabel={language.t("prompt.attachment.remove")}
/>
<Show when={queued.item}>
<div class="px-3 pt-3">
<div class="w-full rounded-lg border border-border-base bg-surface-panel px-2.5 py-2 flex items-center gap-2">
<div class="min-w-0 flex-1">
<div class="text-11-medium text-text-muted">{language.t("prompt.queue.title")}</div>
<div class="text-13-regular text-text-strong truncate">{queued.item?.preview}</div>
</div>
<Button
variant="secondary"
size="small"
class="h-7 px-2"
disabled={queued.busy}
onClick={() => void sendQueuedNow()}
>
{language.t("prompt.queue.sendNow")}
</Button>
<Tooltip placement="top" value={language.t("prompt.queue.remove")}>
<IconButton
icon="trash"
variant="ghost"
class="size-7"
disabled={queued.busy}
onClick={() => void removeQueued(false)}
aria-label={language.t("prompt.queue.remove")}
/>
</Tooltip>
<DropdownMenu open={queued.menu} onOpenChange={(open) => setQueued("menu", open)}>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-7"
aria-label={language.t("prompt.queue.menu")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content placement="bottom-end" gutter={6}>
<DropdownMenu.Item disabled={queued.busy} onSelect={() => void removeQueued(true)}>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</div>
</Show>
<div class="relative max-h-[240px] overflow-y-auto" ref={(el) => (scrollRef = el)}>
<div
data-component="prompt-input"
Expand Down
13 changes: 13 additions & 0 deletions packages/app/src/components/prompt-input/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ type PromptSubmitInput = {
newSessionWorktree?: string
onNewSessionWorktreeReset?: () => void
onSubmit?: () => void
skipQueue?: () => boolean
onQueuedMessage?: (item: { sessionID: string; messageID: string; prompt: Prompt; mode: "normal" | "shell" }) => void
}

type CommentItem = {
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)}
/>
</Show>
</Match>
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/pages/session/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function MessageTimeline(props: {
lastUserMessageID?: string
expanded: Record<string, boolean>
onToggleExpanded: (id: string) => void
suppressAbortedError: (messageID: string) => boolean
}) {
let touchGesture: number | undefined

Expand Down Expand Up @@ -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={{
Expand Down
14 changes: 14 additions & 0 deletions packages/app/src/utils/session-abort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
const suppressed = new Map<string, Set<string>>()

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
}
14 changes: 11 additions & 3 deletions packages/ui/src/components/session-turn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ export function SessionTurn(
stepsExpanded?: boolean
onStepsExpandedToggle?: () => void
onUserInteracted?: () => void
suppressAbortedError?: boolean
classes?: {
root?: string
content?: string
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -731,7 +739,7 @@ export function SessionTurn(
/>
)}
</For>
<Show when={error()}>
<Show when={visibleError()}>
<Card variant="error" class="error-card">
{errorText()}
</Card>
Expand Down Expand Up @@ -787,7 +795,7 @@ export function SessionTurn(
</div>
</div>
</Show>
<Show when={error() && !props.stepsExpanded}>
<Show when={visibleError() && !props.stepsExpanded}>
<Card variant="error" class="error-card">
{errorText()}
</Card>
Expand Down
Loading