fix(v2): keep queued prompts and forms stable

Render pending inbox prompts in their existing message bubbles with queue, steer, edit, and remove actions instead of maintaining a separate queue panel.

Keep duplicate form sync results from replacing the active form object, preventing local field state from resetting and avoiding the resulting virtual timeline height correction.

Validated with UI and server typechecks, focused form, projection, shortcut, bottom-pin, and virtual-follow tests, plus the production UI build.
This commit is contained in:
Pascal André 2026-08-25 22:56:16 +02:00
parent 36be70ac38
commit dde642bc1b
No known key found for this signature in database
10 changed files with 136 additions and 197 deletions

View file

@ -1,6 +1,7 @@
import { For, Index, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack, type Accessor } from "solid-js"
import { Copy, ExternalLink, FoldVertical, Loader2, Volume2, XCircle } from "lucide-solid"
import MessageItem from "./message-item"
import type { SessionInboxUser } from "@opencode-ai/client"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { ClientPart, Message, MessageInfo, TextPart } from "../types/message"
import { isHiddenSyntheticTextPart, partHasRenderableText } from "../types/message"
@ -279,6 +280,11 @@ interface MessageContentItemProps {
messageIndex: number
onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void
pendingPrompt?: SessionInboxUser
pendingPromptBusy?: boolean
onPendingPromptDeliveryChange?: (item: SessionInboxUser) => void
onPendingPromptEdit?: (item: SessionInboxUser) => void
onPendingPromptRemove?: (item: SessionInboxUser) => void
onContentRendered?: () => void
}
@ -367,6 +373,11 @@ function MessageContentItem(props: MessageContentItemProps) {
showAgentMeta={showAgentMeta()}
onRevert={props.onRevert}
onFork={props.onFork}
pendingPrompt={props.pendingPrompt}
pendingPromptBusy={props.pendingPromptBusy}
onPendingPromptDeliveryChange={props.onPendingPromptDeliveryChange}
onPendingPromptEdit={props.onPendingPromptEdit}
onPendingPromptRemove={props.onPendingPromptRemove}
onContentRendered={props.onContentRendered}
/>
</Show>
@ -506,6 +517,11 @@ interface MessageBlockProps {
toolVisibility: (toolName: string) => VisibilityPreference
onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void
pendingPrompt?: SessionInboxUser
pendingPromptBusy?: boolean
onPendingPromptDeliveryChange?: (item: SessionInboxUser) => void
onPendingPromptEdit?: (item: SessionInboxUser) => void
onPendingPromptRemove?: (item: SessionInboxUser) => void
onContentRendered?: () => void
searchQuery?: Accessor<string>
searchResultMessageIds?: Accessor<Set<string>>
@ -768,6 +784,11 @@ export default function MessageBlock(props: MessageBlockProps) {
messageIndex={props.messageIndex}
onRevert={props.onRevert}
onFork={props.onFork}
pendingPrompt={props.pendingPrompt}
pendingPromptBusy={props.pendingPromptBusy}
onPendingPromptDeliveryChange={props.onPendingPromptDeliveryChange}
onPendingPromptEdit={props.onPendingPromptEdit}
onPendingPromptRemove={props.onPendingPromptRemove}
onContentRendered={props.onContentRendered}
/>
</Match>

View file

@ -1,6 +1,7 @@
import { For, Show, createEffect, createSignal, onCleanup } from "solid-js"
import { Portal } from "solid-js/web"
import { Copy, Split, Undo, Volume2 } from "lucide-solid"
import { Copy, Pencil, Play, Split, Trash2, Undo, Volume2 } from "lucide-solid"
import type { SessionInboxUser } from "@opencode-ai/client"
import type { MessageInfo, ClientPart } from "../types/message"
import { isHiddenSyntheticTextPart, partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types"
@ -22,6 +23,11 @@ interface MessageItemProps {
parts: ClientPart[]
onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void
pendingPrompt?: SessionInboxUser
pendingPromptBusy?: boolean
onPendingPromptDeliveryChange?: (item: SessionInboxUser) => void
onPendingPromptEdit?: (item: SessionInboxUser) => void
onPendingPromptRemove?: (item: SessionInboxUser) => void
showAgentMeta?: boolean
contentStartPartId?: string
onContentRendered?: () => void
@ -388,9 +394,35 @@ export default function MessageItem(props: MessageItemProps) {
onSelect: () => void speech.toggle(),
})
}
const pending = props.pendingPrompt
if (pending) {
items.push(
{
key: "pending-delivery",
label: t(`promptQueue.actions.${pending.delivery === "queue" ? "steer" : "queue"}`),
icon: pending.delivery === "queue"
? <Play class="w-3.5 h-3.5" aria-hidden="true" />
: <Undo class="w-3.5 h-3.5" aria-hidden="true" />,
onSelect: () => props.onPendingPromptDeliveryChange?.(pending),
},
{
key: "pending-edit",
label: t("promptQueue.actions.edit"),
icon: <Pencil class="w-3.5 h-3.5" aria-hidden="true" />,
onSelect: () => props.onPendingPromptEdit?.(pending),
},
{
key: "pending-remove",
label: t("promptQueue.actions.remove"),
icon: <Trash2 class="w-3.5 h-3.5" aria-hidden="true" />,
onSelect: () => props.onPendingPromptRemove?.(pending),
},
)
}
}
if (isUser() && props.onFork) {
if (isUser() && !props.pendingPrompt && props.onFork) {
items.push({
key: "fork",
label: t("messageItem.actions.fork"),
@ -399,7 +431,7 @@ export default function MessageItem(props: MessageItemProps) {
})
}
if (isUser() && props.onRevert) {
if (isUser() && !props.pendingPrompt && props.onRevert) {
items.push({
key: "revert",
label: t("messageItem.actions.revertTitle"),
@ -474,6 +506,42 @@ export default function MessageItem(props: MessageItemProps) {
/>
</Show>
<Show when={props.pendingPrompt} keyed>
{(pending) => (
<>
<button
class="message-action-button"
disabled={props.pendingPromptBusy}
onClick={() => props.onPendingPromptDeliveryChange?.(pending)}
title={t(`promptQueue.actions.${pending.delivery === "queue" ? "steer" : "queue"}`)}
aria-label={t(`promptQueue.actions.${pending.delivery === "queue" ? "steer" : "queue"}`)}
>
<Show when={pending.delivery === "queue"} fallback={<Undo class="w-3.5 h-3.5" aria-hidden="true" />}>
<Play class="w-3.5 h-3.5" aria-hidden="true" />
</Show>
</button>
<button
class="message-action-button"
disabled={props.pendingPromptBusy}
onClick={() => props.onPendingPromptEdit?.(pending)}
title={t("promptQueue.actions.edit")}
aria-label={t("promptQueue.actions.edit")}
>
<Pencil class="w-3.5 h-3.5" aria-hidden="true" />
</button>
<button
class="message-action-button"
disabled={props.pendingPromptBusy}
onClick={() => props.onPendingPromptRemove?.(pending)}
title={t("promptQueue.actions.remove")}
aria-label={t("promptQueue.actions.remove")}
>
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</>
)}
</Show>
</div>
<ActionOverflowMenu
items={actionMenuItems()}
@ -536,6 +604,11 @@ export default function MessageItem(props: MessageItemProps) {
<div class="pt-0 whitespace-pre-wrap break-words leading-[1.1]" dir="auto">
<Show when={props.pendingPrompt} keyed>
{(pending) => (
<div class="message-queued-badge">{t(`promptQueue.delivery.${pending.delivery}`)}</div>
)}
</Show>
<Show when={errorMessage()}>
<div class="message-error-block" dir="auto"> {errorMessage()}</div>

View file

@ -26,6 +26,7 @@ import { isLatestWindow, toWindowSnapshot } from "../stores/message-v2/message-w
import { getLogger } from "../lib/logger"
import { beginMessageHistoryTraversal, invalidateMessageHistoryTraversal } from "../stores/session-api"
import { getOpenCodeInstanceGeneration, getOpenCodeMutationRevision } from "../stores/opencode-data"
import type { SessionInboxUser } from "@opencode-ai/client"
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
const QUOTE_SELECTION_MAX_LENGTH = 2000
@ -59,7 +60,11 @@ export interface MessageSectionProps {
sessionStreamingActive?: boolean
explicitBottomPinIntent?: VirtualExplicitBottomPinIntent | null
onExplicitBottomPinCancelled?: () => void
queuedMessageIds?: ReadonlySet<string>
pendingPrompts?: ReadonlyMap<string, SessionInboxUser>
pendingPromptBusy?: boolean
onPendingPromptDeliveryChange?: (item: SessionInboxUser) => void
onPendingPromptEdit?: (item: SessionInboxUser) => void
onPendingPromptRemove?: (item: SessionInboxUser) => void
}
export default function MessageSection(props: MessageSectionProps) {
@ -76,7 +81,6 @@ export default function MessageSection(props: MessageSectionProps) {
const visibleMessageIds = createMemo(() => {
const resolvedStore = store()
return messageIds().filter((messageId) => {
if (props.queuedMessageIds?.has(messageId)) return false
const record = resolvedStore.getMessage(messageId)
if (!record) return false
@ -792,8 +796,7 @@ export default function MessageSection(props: MessageSectionProps) {
isLatest: () => isLatestWindow(store().getMessageWindow(sessionId)),
loadOldest: props.onLoadOldestMessages ?? (() => Promise.resolve()),
loadNewer: props.onLoadNewerMessages ?? (() => Promise.resolve()),
visit: () => buildSessionSearchMatches({ store: store(), sessionId, query, includeThinking })
.filter((match) => !props.queuedMessageIds?.has(match.messageId)),
visit: () => buildSessionSearchMatches({ store: store(), sessionId, query, includeThinking }),
}).then((matches) => {
if (!matches) {
if (isCurrentSearch()) setIsSearchPending(false)
@ -829,7 +832,6 @@ export default function MessageSection(props: MessageSectionProps) {
const includeThinking = Boolean(preferences().showThinkingBlocks)
const currentResidentIds = messageIds()
const currentMatches = buildSessionSearchMatches({ store: store(), sessionId: props.sessionId, query, includeThinking })
.filter((match) => !props.queuedMessageIds?.has(match.messageId))
const frame = requestAnimationFrame(() => {
if (isSearchPending() || !hasMessageSearchAuthority(searchQuery(), query)) return
const activeId = activeSearchMatch()?.id
@ -1186,6 +1188,11 @@ export default function MessageSection(props: MessageSectionProps) {
toolVisibility={(toolName) => resolveToolVisibility(preferences(), toolName)}
onRevert={props.onRevert}
onFork={props.onFork}
pendingPrompt={props.pendingPrompts?.get(messageId)}
pendingPromptBusy={props.pendingPromptBusy}
onPendingPromptDeliveryChange={props.onPendingPromptDeliveryChange}
onPendingPromptEdit={props.onPendingPromptEdit}
onPendingPromptRemove={props.onPendingPromptRemove}
onContentRendered={handleContentRendered}
searchQuery={authoritativeSearchQuery}
searchResultMessageIds={searchResultMessageIds}

View file

@ -1,76 +0,0 @@
import type { SessionInboxUser } from "@opencode-ai/client"
import { For, Show } from "solid-js"
import { Pencil, Play, Trash2, X } from "lucide-solid"
import { useI18n } from "../lib/i18n"
interface PromptQueueProps {
items: SessionInboxUser[]
busyId?: string
editingId?: string
onDeliveryChange: (item: SessionInboxUser) => void
onEdit: (item: SessionInboxUser) => void
onCancelEdit: () => void
onRemove: (item: SessionInboxUser) => void
}
export default function PromptQueue(props: PromptQueueProps) {
const { t } = useI18n()
const text = (item: SessionInboxUser) => {
const display = item.payload.metadata?.displayText
return typeof display === "string" && display ? display : item.payload.text
}
return (
<Show when={props.items.length > 0}>
<section class="prompt-queue" aria-label={t("promptQueue.title", { count: props.items.length })}>
<header class="prompt-queue-header">{t("promptQueue.title", { count: props.items.length })}</header>
<div class="prompt-queue-list">
<For each={props.items}>{(item) => {
const busy = () => Boolean(props.busyId)
const editing = () => props.editingId === item.id
const attachmentCount = () => item.payload.files?.length ?? 0
return (
<div class="prompt-queue-row" data-delivery={item.delivery} data-editing={editing() ? "true" : undefined}>
<div class="prompt-queue-content">
<div class="prompt-queue-meta">
<span>{t("promptQueue.delivery.queue")}</span>
<Show when={attachmentCount() > 0}>
<span>{t(`promptQueue.attachments.${attachmentCount() === 1 ? "one" : "other"}`, { count: attachmentCount() })}</span>
</Show>
</div>
<div class="prompt-queue-text" title={text(item)}>{text(item)}</div>
</div>
<div class="prompt-queue-actions">
<button
type="button"
disabled={busy()}
title={t("promptQueue.actions.steer")}
aria-label={t("promptQueue.actions.steer")}
onClick={() => props.onDeliveryChange(item)}
>
<Play aria-hidden="true" />
</button>
<Show
when={!editing()}
fallback={
<button type="button" disabled={busy()} title={t("promptQueue.actions.cancelEdit")} aria-label={t("promptQueue.actions.cancelEdit")} onClick={props.onCancelEdit}>
<X aria-hidden="true" />
</button>
}
>
<button type="button" disabled={busy()} title={t("promptQueue.actions.edit")} aria-label={t("promptQueue.actions.edit")} onClick={() => props.onEdit(item)}>
<Pencil aria-hidden="true" />
</button>
</Show>
<button type="button" disabled={busy()} title={t("promptQueue.actions.remove")} aria-label={t("promptQueue.actions.remove")} onClick={() => props.onRemove(item)}>
<Trash2 aria-hidden="true" />
</button>
</div>
</div>
)
}}</For>
</div>
</section>
</Show>
)
}

View file

@ -7,7 +7,6 @@ import MessageSection from "../message-section"
import { messageStoreBus } from "../../stores/message-v2/bus"
import PromptInput from "../prompt-input"
import PromptAttachmentsBar from "../prompt-input/PromptAttachmentsBar"
import PromptQueue from "../prompt-queue"
import { getAttachments, hydrateSessionAttachments, removeAttachment } from "../../stores/attachments"
import { instances, waitForInstanceWorkspaceMetadataHydration } from "../../stores/instances"
import { getMessageNextCursor, hasMoreMessages, isLatestMessageWindow, loadLatestMessageWindow, loadMessages, loadMoreMessages, loadNewerMessageWindow, loadOldestMessageWindow, sendMessage, forkSession, renameSession, isSessionMessagesLoading, getSessionMessagesLoadError, markSessionIdleSeen, ensureSessionAncestorsExpanded, setActiveSessionFromList, runShellCommand, abortSession } from "../../stores/sessions"
@ -94,8 +93,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
const attachments = createMemo(() => getAttachments(props.instanceId, props.sessionId))
const pendingUserPrompts = createMemo(() => getOpenCodeSessionInbox(props.instanceId, props.sessionId, props.instanceFolder)
.filter((item): item is SessionInboxUser => item.type === "user"))
const queuedPrompts = createMemo(() => pendingUserPrompts().filter((item) => item.delivery === "queue"))
const queuedMessageIds = createMemo(() => new Set(queuedPrompts().map((item) => item.id)))
const pendingPromptById = createMemo(() => new Map(pendingUserPrompts().map((item) => [item.id, item])))
const preview = createMemo(() => getSessionPreview(props.sessionId))
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
@ -117,7 +115,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
createEffect(() => {
const editing = editingQueuedPrompt()
if (!editing) return
const current = queuedPrompts().find((item) => item.id === editing.id)
const current = pendingPromptById().get(editing.id)
if (!current) cancelQueuedPromptEdit()
else if (current !== editing) setEditingQueuedPrompt(current)
})
@ -129,8 +127,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
let submitBottomPinIntentSequence = 0
function visibleMessageCount() {
const hidden = queuedMessageIds()
return messageStore().getSessionMessageIds(props.sessionId).filter((id) => !hidden.has(id)).length
return messageStore().getSessionMessageIds(props.sessionId).length
}
function shouldScrollToBottomOnActivate() {
@ -633,7 +630,11 @@ export const SessionView: Component<SessionViewProps> = (props) => {
showSidebarToggle={props.showSidebarToggle}
onSidebarToggle={props.onSidebarToggle}
forceCompactStatusLayout={props.forceCompactStatusLayout}
queuedMessageIds={queuedMessageIds()}
pendingPrompts={pendingPromptById()}
pendingPromptBusy={Boolean(queueBusyId())}
onPendingPromptDeliveryChange={(item) => void manageQueuedPrompt(item, "delivery")}
onPendingPromptEdit={handleEditQueuedPrompt}
onPendingPromptRemove={(item) => void manageQueuedPrompt(item, "remove")}
onQuoteSelection={handleQuoteSelection}
/>
}
@ -646,16 +647,6 @@ export const SessionView: Component<SessionViewProps> = (props) => {
/>
</Show>
<PromptQueue
items={queuedPrompts()}
busyId={queueBusyId()}
editingId={editingQueuedPrompt()?.id}
onDeliveryChange={(item) => void manageQueuedPrompt(item, "delivery")}
onEdit={handleEditQueuedPrompt}
onCancelEdit={cancelQueuedPromptEdit}
onRemove={(item) => void manageQueuedPrompt(item, "remove")}
/>
<Show when={attachments().length > 0}>
<PromptAttachmentsBar
attachments={attachments()}

View file

@ -1068,12 +1068,12 @@ export default function ToolCall(props: ToolCallProps) {
)}
</Show>
<Show keyed when={pendingForm()}>
<Show when={pendingForm()}>
{(form) => (
<FormRequest
form={form}
onReply={(answer) => sendFormReply(props.instanceId, form.id, answer)}
onCancel={() => sendFormCancel(props.instanceId, form.id)}
form={form()}
onReply={(answer) => sendFormReply(props.instanceId, form().id, answer)}
onCancel={() => sendFormCancel(props.instanceId, form().id)}
/>
)}
</Show>

View file

@ -10,7 +10,7 @@ import {
sendFormReply,
syncPendingRequests,
} from "./instances.ts"
import { formRequestOptions, getFormQueue } from "./forms.ts"
import { addFormToQueue, clearFormQueue, formRequestOptions, getFormQueue } from "./forms.ts"
import { getRootClient } from "./opencode-client.ts"
import { sdkManager } from "../lib/sdk-manager.ts"
import { sessions, setSessions } from "./session-state.ts"
@ -24,6 +24,18 @@ const form = {
} as any
describe("form interruption lifecycle", () => {
it("keeps an active form object stable across duplicate sync results", () => {
const instanceId = "stable-form-sync"
try {
addFormToQueue(instanceId, form)
const active = getFormQueue(instanceId)[0]
addFormToQueue(instanceId, { ...form })
assert.strictEqual(getFormQueue(instanceId)[0], active)
} finally {
clearFormQueue(instanceId)
}
})
it("marks created or restored forms as pending until reply or cancellation", () => {
const instanceId = "form-lifecycle"
const sessionId = "session-1"

View file

@ -13,9 +13,9 @@ export function addFormToQueue(instanceId: string, form: FormWithLocation): void
const next = new Map(previous)
const queue = next.get(instanceId) ?? []
const index = queue.findIndex((item) => item.id === form.id)
if (index !== -1) return previous
const updated = queue.slice()
if (index === -1) updated.push(form)
else updated[index] = form
updated.push(form)
next.set(instanceId, updated)
return next
})

View file

@ -1,6 +1,5 @@
@import "./messaging/message-base.css";
@import "./messaging/prompt-input.css";
@import "./messaging/prompt-queue.css";
@import "./messaging/message-section.css";
@import "./messaging/virtual-follow-list.css";
@import "./messaging/message-selection.css";

View file

@ -1,88 +0,0 @@
.prompt-queue {
max-height: min(15rem, 34vh);
overflow-y: auto;
border-top: 1px solid var(--border-base);
background: var(--surface-secondary);
}
.prompt-queue-header {
padding: 0.35rem 0.75rem;
color: var(--text-muted);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.prompt-queue-list {
display: flex;
flex-direction: column;
}
.prompt-queue-row {
display: flex;
align-items: center;
min-width: 0;
gap: 0.5rem;
padding: 0.45rem 0.75rem;
border-top: 1px solid var(--border-subtle, var(--border-base));
}
.prompt-queue-row[data-editing="true"] {
box-shadow: inset 3px 0 0 var(--accent-primary);
background: var(--surface-base);
}
.prompt-queue-content {
min-width: 0;
flex: 1;
}
.prompt-queue-meta {
display: flex;
gap: 0.5rem;
color: var(--text-muted);
font-size: 0.68rem;
}
.prompt-queue-text {
overflow: hidden;
color: var(--text-primary);
font-size: 0.78rem;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.prompt-queue-actions {
display: flex;
flex-shrink: 0;
gap: 0.2rem;
}
.prompt-queue-actions button {
display: inline-flex;
width: 1.75rem;
height: 1.75rem;
align-items: center;
justify-content: center;
border: 1px solid transparent;
background: transparent;
color: var(--text-muted);
}
.prompt-queue-actions button:hover:not(:disabled) {
border-color: var(--border-base);
background: var(--surface-base);
color: var(--text-primary);
}
.prompt-queue-actions button:disabled {
cursor: wait;
opacity: 0.45;
}
.prompt-queue-actions svg {
width: 0.9rem;
height: 0.9rem;
}