Skip to content
Merged
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
35 changes: 27 additions & 8 deletions frontend/src/components/common/RobustPromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,22 @@ const RobustPromptInput: FC<RobustPromptInputProps> = ({
pinPrompt,
} = usePromptHistory({ sessionId, specTaskId, projectId, apiClient })

// Canonical "still actionable in the queue" list, failed-first. Computed in ONE
// place so every consumer — queue display, the interrupt toggle, the empty-Enter
// interrupt-promotion, the client-side pump — operates on the SAME set.
// Previously each site recomputed [...failedPrompts, ...pendingPrompts]
// independently, and the promotion path diverged to pendingPrompts-only — so it
// silently skipped a prompt the instant the backend deferred it to 'failed'
// (which a long current turn does almost immediately).
//
// We exclude 'sending' (backend has dispatched to Zed, awaiting first
// message_added): once a message is in flight it can't be promoted/toggled, and
// showing it in the queue until the *next* sync flips it to 'sent' is the lag
// that makes a just-sent prompt linger. Dropping 'sending' hides it optimistically
// the moment dispatch is confirmed; if it later bounces it returns via 'failed'.
// See design/2026-06-19-incident-interrupt-during-boot-context-loss.md.
const queuedPrompts = [...failedPrompts, ...pendingPrompts].filter(p => p.status !== 'sending')

// Track previous appendText to detect changes
const prevAppendTextRef = useRef<string | undefined>(undefined)

Expand All @@ -621,7 +637,7 @@ const RobustPromptInput: FC<RobustPromptInputProps> = ({
if (processingRef.current || !isOnline || disabled) return

// Interrupt-mode messages first, then queue-mode, each oldest-first.
const sortedQueue = [...failedPrompts, ...pendingPrompts].sort((a, b) => {
const sortedQueue = [...queuedPrompts].sort((a, b) => {
const aInterrupt = a.interrupt !== false
const bInterrupt = b.interrupt !== false
if (aInterrupt && !bInterrupt) return -1
Expand Down Expand Up @@ -659,7 +675,7 @@ const RobustPromptInput: FC<RobustPromptInputProps> = ({
setSendingId(null)
processingRef.current = false
}
}, [backendQueueEnabled, isOnline, disabled, failedPrompts, pendingPrompts, sendingId, editingId, onSend, markAsSent, markAsFailed])
}, [backendQueueEnabled, isOnline, disabled, queuedPrompts, sendingId, editingId, onSend, markAsSent, markAsFailed])

// Pump the queue when messages are pending and we're online.
useEffect(() => {
Expand Down Expand Up @@ -750,7 +766,7 @@ const RobustPromptInput: FC<RobustPromptInputProps> = ({
}, [draft, adjustHeight])

// Notify parent when queue changes (affects overall height)
const queueLength = pendingPrompts.length + failedPrompts.length
const queueLength = queuedPrompts.length
useEffect(() => {
if (onHeightChange) {
// Small delay to allow Collapse animation to start
Expand Down Expand Up @@ -820,11 +836,11 @@ const RobustPromptInput: FC<RobustPromptInputProps> = ({

// Toggle interrupt mode for a queued message
const handleToggleInterrupt = useCallback((entryId: string) => {
const entry = [...failedPrompts, ...pendingPrompts].find(e => e.id === entryId)
const entry = queuedPrompts.find(e => e.id === entryId)
if (entry) {
updateInterrupt(entryId, entry.interrupt === false)
}
}, [failedPrompts, pendingPrompts, updateInterrupt])
}, [queuedPrompts, updateInterrupt])

// Restart Zed thread after a Claude Agent crash. Calls the backend endpoint
// which clears the dead acp_thread_id and resets crashed prompts back to
Expand Down Expand Up @@ -938,7 +954,10 @@ const RobustPromptInput: FC<RobustPromptInputProps> = ({
// Empty field: promote most-recent queued entry to interrupt instead of sending nothing.
if (!content && attachments.length === 0) {
if (disabled) return
const candidates = pendingPrompts.filter(p =>
// Promote the most-recent NON-interrupt queued message to interrupt.
// Scans queuedPrompts (failed + pending) so a deferred message — the one
// the user is actually trying to escalate — is still a candidate.
const candidates = queuedPrompts.filter(p =>
p.interrupt === false &&
!p.deleted &&
p.id !== sendingId &&
Expand Down Expand Up @@ -998,7 +1017,7 @@ const RobustPromptInput: FC<RobustPromptInputProps> = ({
e.preventDefault()
}
}
}, [draft, disabled, attachments, saveToHistory, clearDraft, navigateUp, navigateDown, pendingPrompts, updateInterrupt, sendingId, editingId])
}, [draft, disabled, attachments, saveToHistory, clearDraft, navigateUp, navigateDown, queuedPrompts, updateInterrupt, sendingId, editingId])

// Add a file as an attachment (queues for upload, uploads if online)
const addFileAsAttachment = useCallback((file: File): string => {
Expand Down Expand Up @@ -1219,7 +1238,7 @@ const RobustPromptInput: FC<RobustPromptInputProps> = ({
}

// All queued messages (pending + failed), sorted: interrupt mode first, then queue mode
const queuedMessages = [...failedPrompts, ...pendingPrompts].sort((a, b) => {
const queuedMessages = [...queuedPrompts].sort((a, b) => {
// Interrupt mode (true or undefined) comes first
const aInterrupt = a.interrupt !== false
const bInterrupt = b.interrupt !== false
Expand Down
19 changes: 16 additions & 3 deletions frontend/src/hooks/usePromptHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,19 @@ export function usePromptHistory({
// IDs that are locally tombstoned — never re-import these from backend
const deletedIds = new Set(prev.filter(e => e.deleted).map(e => e.id))

// Mark existing entries that are in backend as synced (skip deleted ones)
// Mark existing entries that are in backend as synced (skip deleted ones).
// CRITICAL: never re-confirm an entry that has an un-pushed local change
// (syncedToBackend === false, set by updateInterrupt/updateContent). A pull
// confirms the backend has *some* version, not the local one — flipping a
// dirty entry back to "synced" makes the next syncToBackend skip it, so the
// local change is silently dropped. This is exactly how promoting a queued
// prompt to interrupt showed the lightning in the UI while the backend kept
// interrupt=false: a backend poll landed between the promote and the push.
// See design/2026-06-19-incident-interrupt-during-boot-context-loss.md.
const updatedPrev = prev.map(e =>
backendIds.has(e.id) && !e.deleted ? { ...e, syncedToBackend: true } : e
backendIds.has(e.id) && !e.deleted && e.syncedToBackend !== false
? { ...e, syncedToBackend: true }
: e
)

// Add any backend entries that don't exist locally (mark as synced)
Expand Down Expand Up @@ -443,7 +453,10 @@ export function usePromptHistory({
retryCount: backendEntry.retryCount,
nextRetryAt: backendEntry.nextRetryAt,
errorMessage: backendEntry.errorMessage,
syncedToBackend: true
// Reflect backend-owned status, but PRESERVE a pending local
// change (e.g. an interrupt promotion not yet pushed) — don't
// clobber the dirty flag, or syncToBackend will skip the push.
syncedToBackend: h.syncedToBackend === false ? false : true,
}
}
// Reconcile against the source of truth: a queue entry we previously
Expand Down
Loading