mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-08 01:43:07 +00:00
* fix(web): force-dismiss indeterminate queued rows on cancel/edit When cancel returns busy for an already-indeterminate QueuedMessagesBar row, keep the optimistic removal instead of re-sticking the row so Edit/X cannot leave the UI dead. Upgrade busy to invoked when queued-state reconcile shows the steer already landed, so Edit toasts instead of prefilling a duplicate. Fixes #1839 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): never prefill Edit when queued cancel returns busy Indeterminate rows can still be a live dispatch. Prefilling on busy risked a duplicate send if the original steer later landed. Keep force-dismiss for Cancel; toast on Edit+busy without restoring the draft. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): keep force-dismissed indeterminate rows for late consume Hide abandoned outcome-unknown rows from the queued bar via queueDismissed so Cancel/Edit still clear the stuck chip, while leaving the message in the window for a later messages-consumed SSE. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): close queueDismissed races on cancel busy and requeue Restore the hidden hold before getQueuedState so concurrent consume SSE can land, skip overwriting it in onSuccess, and clear dismissal when markMessagesRequeued confirms FIFO again. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): undismiss force-held row when cancel busy finds FIFO If getQueuedState reports the localId is queued again after a failed steer, clear queueDismissed so Edit/Cancel return without prefilling. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): preserve queueDismissed across refresh and merges Force-dismiss of indeterminate rows survived Cancel, but latest-page refetch and same-ID merges dropped the client-only flag. Carry it while the authoritative row remains uninvoked and indeterminate. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): clear queueDismissed on reconnect FIFO reconcile Reconnect queued-state reconciliation now markMessagesRequeued for confirmed queuedLocalIds so force-dismissed rows regain Edit/Cancel after a missed messages-requeued SSE. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
210 lines
9.0 KiB
TypeScript
210 lines
9.0 KiB
TypeScript
import type { DecryptedMessage } from '@/types/api'
|
|
import { randomId } from '@/lib/randomId'
|
|
|
|
export function makeClientSideId(prefix: string): string {
|
|
return `${prefix}-${randomId()}`
|
|
}
|
|
|
|
export function isUserMessage(msg: DecryptedMessage): boolean {
|
|
const content = msg.content
|
|
if (content && typeof content === 'object' && 'role' in content) {
|
|
return (content as { role: string }).role === 'user'
|
|
}
|
|
return false
|
|
}
|
|
|
|
/** A user message that is still waiting for the CLI ack (messages-consumed).
|
|
* Strict null on `invokedAt` so a pre-V8 hub response that omits the field
|
|
* (`undefined`) is treated as already-invoked; only optimistic / V8-loaded
|
|
* rows that explicitly carry `invokedAt: null` are queued. `failed` rows are
|
|
* not queued either — they're surfaced as send errors, not pending work. */
|
|
export function isQueuedForInvocation(msg: DecryptedMessage): boolean {
|
|
return isUserMessage(msg) && msg.invokedAt === null && msg.status !== 'failed'
|
|
}
|
|
|
|
function isOptimisticMessage(msg: DecryptedMessage): boolean {
|
|
return Boolean(msg.localId && msg.id === msg.localId)
|
|
}
|
|
|
|
function compareMessages(a: DecryptedMessage, b: DecryptedMessage): number {
|
|
const aTime = a.invokedAt ?? a.createdAt
|
|
const bTime = b.invokedAt ?? b.createdAt
|
|
|
|
if (aTime !== bTime) {
|
|
return aTime - bTime
|
|
}
|
|
|
|
const aSeq = typeof a.seq === 'number' ? a.seq : null
|
|
const bSeq = typeof b.seq === 'number' ? b.seq : null
|
|
|
|
if (aSeq !== null && bSeq !== null && aSeq !== bSeq) {
|
|
return aSeq - bSeq
|
|
}
|
|
return a.id.localeCompare(b.id)
|
|
}
|
|
|
|
// Invariant: a message the CLI has consumed (invokedAt set) is delivered, not
|
|
// queued. A message delivered immediately — e.g. steered into the active turn —
|
|
// can carry a stale optimistic 'queued' status while its invokedAt is already
|
|
// set; normalize so the queued clock never lingers on a delivered message.
|
|
function clearStaleQueuedStatus(list: DecryptedMessage[]): DecryptedMessage[] {
|
|
return list.map((msg) =>
|
|
msg.status === 'queued' && msg.invokedAt != null
|
|
? { ...msg, status: 'sent' as DecryptedMessage['status'] }
|
|
: msg
|
|
)
|
|
}
|
|
|
|
export function mergeMessages(existing: DecryptedMessage[], incoming: DecryptedMessage[]): DecryptedMessage[] {
|
|
if (existing.length === 0) {
|
|
return clearStaleQueuedStatus([...incoming]).sort(compareMessages)
|
|
}
|
|
if (incoming.length === 0) {
|
|
return clearStaleQueuedStatus([...existing]).sort(compareMessages)
|
|
}
|
|
|
|
const byId = new Map<string, DecryptedMessage>()
|
|
for (const msg of existing) {
|
|
byId.set(msg.id, msg)
|
|
}
|
|
for (const msg of incoming) {
|
|
const existing = byId.get(msg.id)
|
|
if (existing) {
|
|
// Preserve client-only signals the incoming (server) copy can't carry:
|
|
// a late ack timestamp, the live 'steered' marker (not persisted), and
|
|
// force-dismiss of an indeterminate queued row (#1839).
|
|
const preserved: Partial<DecryptedMessage> = {}
|
|
if (existing.invokedAt != null && msg.invokedAt == null) {
|
|
preserved.invokedAt = existing.invokedAt
|
|
}
|
|
if (existing.steered && !msg.steered) {
|
|
preserved.steered = true
|
|
}
|
|
if (
|
|
existing.queueDismissed
|
|
&& msg.invokedAt === null
|
|
&& msg.deliveryState === 'indeterminate'
|
|
) {
|
|
preserved.queueDismissed = true
|
|
}
|
|
byId.set(msg.id, Object.keys(preserved).length > 0 ? { ...msg, ...preserved } : msg)
|
|
} else {
|
|
byId.set(msg.id, msg)
|
|
}
|
|
}
|
|
|
|
let merged = Array.from(byId.values())
|
|
|
|
const incomingStoredLocalIds = new Set<string>()
|
|
for (const msg of incoming) {
|
|
if (msg.localId && !isOptimisticMessage(msg)) {
|
|
incomingStoredLocalIds.add(msg.localId)
|
|
}
|
|
}
|
|
|
|
// If we received stored messages with a localId, drop any optimistic bubbles with the same localId.
|
|
// Preserve client-side status (e.g. 'queued') and invokedAt on the replacing server message.
|
|
if (incomingStoredLocalIds.size > 0) {
|
|
const optimisticStatusByLocalId = new Map<string, DecryptedMessage['status']>()
|
|
const optimisticInvokedAtByLocalId = new Map<string, number | null | undefined>()
|
|
const optimisticSteeredByLocalId = new Map<string, boolean>()
|
|
const optimisticQueueDismissedByLocalId = new Map<string, boolean>()
|
|
for (const msg of merged) {
|
|
if (msg.localId && isOptimisticMessage(msg) && incomingStoredLocalIds.has(msg.localId)) {
|
|
if (msg.status) {
|
|
optimisticStatusByLocalId.set(msg.localId, msg.status)
|
|
}
|
|
if (msg.invokedAt !== undefined) {
|
|
optimisticInvokedAtByLocalId.set(msg.localId, msg.invokedAt)
|
|
}
|
|
if (msg.steered) {
|
|
optimisticSteeredByLocalId.set(msg.localId, true)
|
|
}
|
|
if (msg.queueDismissed) {
|
|
optimisticQueueDismissedByLocalId.set(msg.localId, true)
|
|
}
|
|
}
|
|
}
|
|
merged = merged.filter((msg) => {
|
|
if (!msg.localId || !incomingStoredLocalIds.has(msg.localId)) {
|
|
return true
|
|
}
|
|
return !isOptimisticMessage(msg)
|
|
})
|
|
if (
|
|
optimisticStatusByLocalId.size > 0
|
|
|| optimisticInvokedAtByLocalId.size > 0
|
|
|| optimisticSteeredByLocalId.size > 0
|
|
|| optimisticQueueDismissedByLocalId.size > 0
|
|
) {
|
|
merged = merged.map((msg) => {
|
|
if (!msg.localId) return msg
|
|
const update: Partial<DecryptedMessage> = {}
|
|
if (optimisticStatusByLocalId.has(msg.localId) && !msg.status) {
|
|
const optimisticStatus = optimisticStatusByLocalId.get(msg.localId)
|
|
// Don't carry an optimistic 'queued' status onto a server message
|
|
// the CLI has already consumed (invokedAt set). This happens when a
|
|
// message is delivered immediately — e.g. steered into the active
|
|
// turn — so its server echo arrives pre-invoked; inheriting 'queued'
|
|
// would pin the queued clock on an already-delivered message.
|
|
if (optimisticStatus !== 'queued' || msg.invokedAt == null) {
|
|
update.status = optimisticStatus
|
|
}
|
|
}
|
|
if (optimisticInvokedAtByLocalId.has(msg.localId) && msg.invokedAt == null) {
|
|
const optimisticInvokedAt = optimisticInvokedAtByLocalId.get(msg.localId)
|
|
if (optimisticInvokedAt != null) {
|
|
update.invokedAt = optimisticInvokedAt
|
|
}
|
|
}
|
|
// The 'steered' marker is live-only (the hub never persists it), so
|
|
// carry it from the optimistic row onto the replacing server echo —
|
|
// otherwise the ↳ Steered badge vanishes the moment the echo lands.
|
|
if (optimisticSteeredByLocalId.has(msg.localId) && !msg.steered) {
|
|
update.steered = true
|
|
}
|
|
if (
|
|
optimisticQueueDismissedByLocalId.has(msg.localId)
|
|
&& msg.invokedAt === null
|
|
&& msg.deliveryState === 'indeterminate'
|
|
&& !msg.queueDismissed
|
|
) {
|
|
update.queueDismissed = true
|
|
}
|
|
if (Object.keys(update).length > 0) {
|
|
return { ...msg, ...update }
|
|
}
|
|
return msg
|
|
})
|
|
}
|
|
}
|
|
|
|
// Fallback: if an optimistic message was marked as sent but we didn't get a localId echo,
|
|
// drop it when a server user message appears close in time.
|
|
const optimisticMessages = merged.filter((m) => isOptimisticMessage(m))
|
|
const nonOptimisticMessages = merged.filter((m) => !isOptimisticMessage(m))
|
|
const result: DecryptedMessage[] = [...nonOptimisticMessages]
|
|
|
|
for (const optimistic of optimisticMessages) {
|
|
if (optimistic.status === 'sent') {
|
|
// Compare by the position key (invokedAt ?? createdAt). A late ack can
|
|
// attach `invokedAt` long after `createdAt`, so the optimistic copy and
|
|
// the server echo end up at the same byPosition slot — using
|
|
// `createdAt` alone misses that match and renders both as duplicates.
|
|
const optimisticTime = optimistic.invokedAt ?? optimistic.createdAt
|
|
const hasServerUserMessage = nonOptimisticMessages.some((m) =>
|
|
isUserMessage(m) &&
|
|
Math.abs((m.invokedAt ?? m.createdAt) - optimisticTime) < 10_000
|
|
)
|
|
if (hasServerUserMessage) {
|
|
continue
|
|
}
|
|
}
|
|
result.push(optimistic)
|
|
}
|
|
|
|
const normalized = clearStaleQueuedStatus(result)
|
|
normalized.sort(compareMessages)
|
|
return normalized
|
|
}
|