From 36be70ac380fbe359a716809aa2ea98af45f6ed6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Tue, 25 Aug 2026 22:44:57 +0200 Subject: [PATCH] fix(v2): make queue mutations reconnect-safe Remove queue reordering because the native inbox contract cannot perform it atomically and suffix replacement could duplicate or prematurely execute prompts. Serialize visible queue actions and keep edit replacement limited to the native admit-then-cancel safety guarantee. Rebind mounted inbox observers after a reconnect generation, isolate per-session inbox refresh failures, avoid duplicate Shell refreshes, and exclude hidden queued prompts from transcript search. Queue an alternate submission when another admission is still in flight. Validated with UI and server typechecks, 84 focused session/data tests, 46 shortcut/scroll tests, and a production UI build. --- MIGRATION_V2.md | 2 +- packages/ui/src/App.tsx | 2 - .../ui/src/components/message-section.tsx | 4 +- packages/ui/src/components/prompt-input.tsx | 6 +- packages/ui/src/components/prompt-queue.tsx | 13 +--- .../src/components/session/session-view.tsx | 66 +------------------ .../ui/src/lib/i18n/messages/de/messaging.ts | 2 - .../ui/src/lib/i18n/messages/en/messaging.ts | 2 - .../ui/src/lib/i18n/messages/es/messaging.ts | 2 - .../ui/src/lib/i18n/messages/fr/messaging.ts | 2 - .../ui/src/lib/i18n/messages/he/messaging.ts | 2 - .../ui/src/lib/i18n/messages/ja/messaging.ts | 2 - .../ui/src/lib/i18n/messages/ne/messaging.ts | 2 - .../ui/src/lib/i18n/messages/ru/messaging.ts | 2 - .../lib/i18n/messages/zh-Hans/messaging.ts | 2 - packages/ui/src/stores/instances.ts | 10 +-- packages/ui/src/stores/opencode-data.test.ts | 35 +++++++++- packages/ui/src/stores/opencode-data.ts | 12 ++++ 18 files changed, 66 insertions(+), 102 deletions(-) diff --git a/MIGRATION_V2.md b/MIGRATION_V2.md index c8bb3ecb..0a2aec5d 100644 --- a/MIGRATION_V2.md +++ b/MIGRATION_V2.md @@ -143,7 +143,7 @@ During stabilization, CodeNomad implements the public native V2 contract and rem | Delete-to-boundary / undo | `session.revert.stage` and `session.revert.clear` use V2 staged-revert semantics instead of arbitrary deletion. | Undo uses `revert.stage`. Redo/clear remains a CodeNomad UI gap, not a reason to restore V1 deletion. | Expose `revert.clear` through the existing native contract. | | Compaction | Native checkpoint compaction summarizes the older head and retains a server-selected recent tail controlled by `compaction.keep.tokens`. | Uses `session.compact`; there is no message-level selective compaction. CodeNomad displays the terminal summary without rendering every streamed delta. | Add scoped controls only if V2 defines scoped compaction semantics. | | Full-session search | Native message cursors exist, but there is no server search endpoint that returns message identity and position. | Retained through bounded cursor traversal while keeping only the 200-message resident window and collected matches. Large searches still fetch every page. | V2 exposes server search plus a rank/cursor navigation target. | -| Queued prompt management | Native inbox list, cancel, steer, and queue operations are available. | Implemented with authoritative queue/steer switching, cancellation, safe edit replacement, draft preservation, order-preserving suffix rewrites, and queue admission for follow-up prompts. | Replace suffix rewrites if V2 adds atomic inbox update and reorder operations. | +| Queued prompt management | Native inbox list, cancel, steer, and queue operations are available. | Implemented with authoritative steering, cancellation, safe edit replacement, draft preservation, and queue admission for follow-up prompts. | Add in-place editing and reordering when V2 exposes atomic inbox mutations; replacement currently moves an edited prompt to the queue tail. | | Background execution | Native Shell resources support listing, bounded output, and removal, but do not reproduce every custom V1 process-manager control. | Replaced the custom manager with native Shells and removed unsupported controls such as rename. | Add controls only when native Shell APIs support them. | | Service lifecycle | V2 uses one shared externally owned service rather than one runtime per workspace. | CodeNomad discovers or starts the service but never exposes workspace stop or stops the daemon on shutdown. | No parity work planned unless V2 changes service ownership semantics. | diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index ff35da08..c0bda8de 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -50,7 +50,6 @@ import { syncLoadedSessionInboxes, syncPendingRequests, } from "./stores/instances" -import { shellStore } from "./stores/shells" import { getSessions, getSessionRoot, @@ -333,7 +332,6 @@ const App: Component = () => { syncPendingRequests(id, (invalidate) => { invalidatePendingRequests = invalidate }), refreshVolatileInstanceState(id), syncLoadedSessionInboxes(id), - shellStore.refreshForEvent(id, { type: "server.connected" }), ]) if (sessionError) throw sessionError })(), diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index 38604d7b..ab181760 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -792,7 +792,8 @@ 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 }), + visit: () => buildSessionSearchMatches({ store: store(), sessionId, query, includeThinking }) + .filter((match) => !props.queuedMessageIds?.has(match.messageId)), }).then((matches) => { if (!matches) { if (isCurrentSearch()) setIsSearchPending(false) @@ -828,6 +829,7 @@ 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 diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index 62f63bd5..824e368c 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -95,6 +95,7 @@ export default function PromptInput(props: PromptInputProps) { let wrapperRef: HTMLDivElement | undefined let fieldContainerRef: HTMLDivElement | undefined let resizeDragState: ResizeDragState | undefined + let submissionsInFlight = 0 const getPlaceholder = () => { if (mode() === "shell") { @@ -519,6 +520,7 @@ export default function PromptInput(props: PromptInputProps) { focusConversationStream(wrapperRef?.closest(".session-view")) } + submissionsInFlight += 1 try { if (isShellMode) { if (props.onRunShell) { @@ -546,6 +548,8 @@ export default function PromptInput(props: PromptInputProps) { textareaRef?.focus() } return + } finally { + submissionsInFlight -= 1 } } @@ -737,7 +741,7 @@ export default function PromptInput(props: PromptInputProps) { getAttachments: attachments, removeAttachment: (attachmentId) => removeAttachment(props.instanceId, props.sessionId, attachmentId), submitOnEnter, - onSend: (alternate) => void handleSend(alternate && props.isSessionBusy ? "queue" : "steer"), + onSend: (alternate) => void handleSend(alternate && (props.isSessionBusy || submissionsInFlight > 0) ? "queue" : "steer"), selectPreviousHistory: (force) => selectPreviousHistory({ force, isPickerOpen: showPicker(), getTextarea: () => textareaRef ?? null }), selectNextHistory: (force) => diff --git a/packages/ui/src/components/prompt-queue.tsx b/packages/ui/src/components/prompt-queue.tsx index 1e41ca3d..2e3908c4 100644 --- a/packages/ui/src/components/prompt-queue.tsx +++ b/packages/ui/src/components/prompt-queue.tsx @@ -1,6 +1,6 @@ import type { SessionInboxUser } from "@opencode-ai/client" import { For, Show } from "solid-js" -import { ArrowDown, ArrowUp, Pencil, Play, Trash2, X } from "lucide-solid" +import { Pencil, Play, Trash2, X } from "lucide-solid" import { useI18n } from "../lib/i18n" interface PromptQueueProps { @@ -11,7 +11,6 @@ interface PromptQueueProps { onEdit: (item: SessionInboxUser) => void onCancelEdit: () => void onRemove: (item: SessionInboxUser) => void - onMove: (item: SessionInboxUser, direction: -1 | 1) => void } export default function PromptQueue(props: PromptQueueProps) { @@ -26,8 +25,8 @@ export default function PromptQueue(props: PromptQueueProps) {
{t("promptQueue.title", { count: props.items.length })}
- {(item, index) => { - const busy = () => props.busyId === item.id + {(item) => { + const busy = () => Boolean(props.busyId) const editing = () => props.editingId === item.id const attachmentCount = () => item.payload.files?.length ?? 0 return ( @@ -51,12 +50,6 @@ export default function PromptQueue(props: PromptQueueProps) { >