fix(web): preserve queued edits across cancellation (#1324)

This commit is contained in:
KorenKrita
2026-08-03 09:25:45 +08:00
committed by GitHub
parent 52dda73045
commit 413fbb8714
7 changed files with 981 additions and 39 deletions
@@ -1,7 +1,457 @@
import { describe, expect, it } from 'vitest'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DecryptedMessage } from '@/types/api'
import { computeCanCancel, computeEditPendingSchedule, getQueuedMessageEditText, getQueuedMessagePreview, sortQueuedMessages } from './QueuedMessagesBar'
import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
import {
computeCanCancel,
computeEditPendingSchedule,
getQueuedMessageEditText,
getQueuedMessagePreview,
QueuedMessagesBar,
sortQueuedMessages,
} from './QueuedMessagesBar'
import { formatScheduledTime } from '@/lib/scheduledTime'
import { clearQueuedEditRecovery, getQueuedEditRecovery } from '@/lib/queued-edit-recovery'
type DeferredCancelResult = { status: 'cancelled' | 'invoked' }
const mocks = vi.hoisted(() => ({
composerText: '',
composerSetText: vi.fn(),
addToast: vi.fn(),
mutateAsync: vi.fn(),
resolveCancel: null as ((result: DeferredCancelResult) => void) | null,
rejectCancel: null as ((reason?: unknown) => void) | null,
saveDraft: vi.fn(),
messageWindowState: { messages: [] as unknown[] },
}))
vi.mock('@assistant-ui/react', () => ({
useAui: () => ({
composer: () => ({
getState: () => ({ text: mocks.composerText }),
setText: mocks.composerSetText,
}),
}),
useAuiState: (selector: (state: { composer: { text: string } }) => unknown) => selector({
composer: { text: mocks.composerText },
}),
}))
vi.mock('@/lib/message-window-store', () => ({
getMessageWindowState: () => mocks.messageWindowState,
subscribeMessageWindow: () => () => {},
}))
vi.mock('@/hooks/mutations/useCancelQueuedMessage', () => ({
useCancelQueuedMessage: () => ({
isPending: false,
variables: undefined,
mutateAsync: mocks.mutateAsync,
}),
}))
vi.mock('@/lib/composer-drafts', () => ({
saveDraft: mocks.saveDraft,
}))
vi.mock('@/lib/use-translation', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('@/lib/toast-context', () => ({
useToast: () => ({ addToast: mocks.addToast }),
}))
function makeQueuedMessage(scheduledAt: number | null = null, id = 'server-message-id'): DecryptedMessage {
return {
id,
localId: `local-${id}`,
createdAt: 1000,
seq: 1,
scheduledAt,
invokedAt: null,
status: 'queued',
content: {
role: 'user',
content: { type: 'text', text: 'Queued request' },
},
} as unknown as DecryptedMessage
}
function renderQueuedMessage(
scheduledAt: number | null = null,
pendingSchedule: PendingSchedule | null = null,
pendingScheduleRevision = 0,
) {
const onEdit = vi.fn()
let currentPendingScheduleRevision = pendingScheduleRevision
mocks.messageWindowState = { messages: [makeQueuedMessage(scheduledAt)] }
const view = render(
<QueuedMessagesBar
sessionId="session-1"
api={null}
pendingSchedule={pendingSchedule}
pendingScheduleRevision={currentPendingScheduleRevision}
onEdit={onEdit}
/>
)
return {
onEdit,
unmount: view.unmount,
rerender: (nextPendingSchedule: PendingSchedule | null, nextPendingScheduleRevision = currentPendingScheduleRevision) => {
currentPendingScheduleRevision = nextPendingScheduleRevision
view.rerender(
<QueuedMessagesBar
sessionId="session-1"
api={null}
pendingSchedule={nextPendingSchedule}
pendingScheduleRevision={currentPendingScheduleRevision}
onEdit={onEdit}
/>
)
},
}
}
beforeEach(() => {
mocks.composerText = ''
mocks.composerSetText.mockReset()
mocks.addToast.mockReset()
mocks.mutateAsync.mockReset()
mocks.resolveCancel = null
mocks.rejectCancel = null
mocks.saveDraft.mockReset()
mocks.messageWindowState = { messages: [] }
clearQueuedEditRecovery('session-1')
mocks.mutateAsync.mockImplementation(() => new Promise<DeferredCancelResult>((resolve, reject) => {
mocks.resolveCancel = resolve
mocks.rejectCancel = reject
}))
})
async function resolveCancel(result: DeferredCancelResult): Promise<void> {
await act(async () => {
mocks.resolveCancel?.(result)
await Promise.resolve()
})
}
function installManualAnimationFrames() {
let nextHandle = 1
const pending = new Map<number, FrameRequestCallback>()
const history = new Map<number, FrameRequestCallback>()
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
const handle = nextHandle++
pending.set(handle, callback)
history.set(handle, callback)
return handle
})
vi.stubGlobal('cancelAnimationFrame', (handle: number) => {
pending.delete(handle)
})
return {
flushPending() {
while (pending.size > 0) {
const [handle, callback] = pending.entries().next().value as [number, FrameRequestCallback]
pending.delete(handle)
callback(Date.now())
}
},
flushHistory() {
for (const callback of history.values()) {
callback(Date.now())
}
},
}
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe('QueuedMessagesBar edit restore', () => {
it('keeps a newly typed draft and its schedule when the deferred cancel succeeds', async () => {
const scheduledAt = Date.now() + 60_000
const { onEdit } = renderQueuedMessage(scheduledAt)
fireEvent.click(screen.getByRole('button', { name: 'Edit queued message' }))
expect(mocks.resolveCancel).not.toBeNull()
// The user starts a new draft while DELETE /messages/:id is still pending.
mocks.composerText = 'New draft typed while cancelling'
await resolveCancel({ status: 'cancelled' })
expect(mocks.composerSetText).not.toHaveBeenCalled()
expect(onEdit).not.toHaveBeenCalled()
expect(mocks.addToast).toHaveBeenCalledWith({
title: 'queuedMessages.editCurrentDraftKept',
body: '',
sessionId: 'session-1',
url: window.location.href,
})
})
it('keeps a newly selected schedule when the composer text is unchanged', async () => {
const scheduledAt = Date.now() + 60_000
const { onEdit, rerender } = renderQueuedMessage(scheduledAt)
fireEvent.click(screen.getByRole('button', { name: 'Edit queued message' }))
// The user changes only the clock selection while DELETE /messages/:id is pending.
rerender({ type: 'preset', preset: '+30m' }, 1)
await resolveCancel({ status: 'cancelled' })
expect(mocks.composerSetText).not.toHaveBeenCalled()
expect(onEdit).not.toHaveBeenCalled()
expect(mocks.addToast).toHaveBeenCalledWith({
title: 'queuedMessages.editCurrentDraftKept',
body: '',
sessionId: 'session-1',
url: window.location.href,
})
})
it('keeps the current state after selecting and then clearing a schedule', async () => {
const scheduledAt = Date.now() + 60_000
const { onEdit, rerender } = renderQueuedMessage(scheduledAt)
fireEvent.click(screen.getByRole('button', { name: 'Edit queued message' }))
rerender({ type: 'preset', preset: '+30m' }, 1)
rerender(null, 2)
await resolveCancel({ status: 'cancelled' })
expect(mocks.composerSetText).not.toHaveBeenCalled()
expect(onEdit).not.toHaveBeenCalled()
expect(mocks.addToast).toHaveBeenCalledWith({
title: 'queuedMessages.editCurrentDraftKept',
body: '',
sessionId: 'session-1',
url: window.location.href,
})
})
it('restores both text and schedule when the composer is unchanged', async () => {
const scheduledAt = Date.now() + 60_000
const { onEdit } = renderQueuedMessage(scheduledAt)
fireEvent.click(screen.getByRole('button', { name: 'Edit queued message' }))
await resolveCancel({ status: 'cancelled' })
expect(mocks.composerSetText).toHaveBeenCalledWith('Queued request')
expect(onEdit).toHaveBeenCalledWith({
text: 'Queued request',
pendingSchedule: { type: 'absolute', ms: scheduledAt },
})
expect(mocks.addToast).not.toHaveBeenCalled()
})
it('treats structurally equal schedule props as unchanged', async () => {
const scheduledAt = Date.now() + 60_000
const { onEdit, rerender } = renderQueuedMessage(scheduledAt, { type: 'preset', preset: '+5m' })
fireEvent.click(screen.getByRole('button', { name: 'Edit queued message' }))
rerender({ type: 'preset', preset: '+5m' })
await resolveCancel({ status: 'cancelled' })
expect(mocks.composerSetText).toHaveBeenCalledWith('Queued request')
expect(onEdit).toHaveBeenCalledWith({
text: 'Queued request',
pendingSchedule: { type: 'absolute', ms: scheduledAt },
})
})
it('globally disables queued operations and keeps the first edit completion', async () => {
const scheduledAt = Date.now() + 60_000
const first = makeQueuedMessage(null, 'server-message-a')
const second = makeQueuedMessage(scheduledAt, 'server-message-b')
mocks.messageWindowState = { messages: [first, second] }
const onEdit = vi.fn()
render(
<QueuedMessagesBar
sessionId="session-1"
api={null}
pendingSchedule={null}
pendingScheduleRevision={0}
onEdit={onEdit}
/>
)
const editButtons = screen.getAllByRole('button', { name: 'Edit queued message' })
const cancelButtons = screen.getAllByRole('button', { name: 'Cancel queued message' })
// QueuedMessagesBar orders immediate rows first, so the scheduled edit is second.
fireEvent.click(editButtons[1]!)
await waitFor(() => {
for (const button of [...screen.getAllByRole('button', { name: 'Edit queued message' }), ...screen.getAllByRole('button', { name: 'Cancel queued message' })]) {
expect(button).toBeDisabled()
}
})
fireEvent.click(cancelButtons[0]!)
fireEvent.click(editButtons[0]!)
expect(mocks.mutateAsync).toHaveBeenCalledTimes(1)
await resolveCancel({ status: 'cancelled' })
expect(mocks.composerSetText).toHaveBeenCalledWith('Queued request')
expect(onEdit).toHaveBeenCalledWith({
text: 'Queued request',
pendingSchedule: { type: 'absolute', ms: scheduledAt },
})
})
it('persists an unmounted edit result and restores it after the same session remounts', async () => {
const scheduledAt = Date.now() + 60_000
const { unmount } = renderQueuedMessage(scheduledAt)
fireEvent.click(screen.getByRole('button', { name: 'Edit queued message' }))
unmount()
await resolveCancel({ status: 'cancelled' })
expect(mocks.saveDraft).not.toHaveBeenCalled()
expect(getQueuedEditRecovery('session-1')).toEqual(expect.objectContaining({
text: 'Queued request',
pendingSchedule: { type: 'absolute', ms: scheduledAt },
composerTextAtEdit: '',
pendingScheduleAtEdit: null,
}))
const { onEdit } = renderQueuedMessage(scheduledAt)
await waitFor(() => expect(mocks.composerSetText).toHaveBeenCalledWith('Queued request'))
expect(onEdit).toHaveBeenCalledWith({
text: 'Queued request',
pendingSchedule: { type: 'absolute', ms: scheduledAt },
})
expect(getQueuedEditRecovery('session-1')).toBeNull()
})
it('notifies a same-session remount that returns before the edit cancellation completes', async () => {
const scheduledAt = Date.now() + 60_000
const { unmount } = renderQueuedMessage(scheduledAt)
fireEvent.click(screen.getByRole('button', { name: 'Edit queued message' }))
unmount()
const { onEdit } = renderQueuedMessage(scheduledAt)
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Edit queued message' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Cancel queued message' })).toBeDisabled()
})
fireEvent.click(screen.getByRole('button', { name: 'Cancel queued message' }))
expect(mocks.mutateAsync).toHaveBeenCalledTimes(1)
await resolveCancel({ status: 'cancelled' })
await waitFor(() => expect(mocks.composerSetText).toHaveBeenCalledWith('Queued request'))
expect(onEdit).toHaveBeenCalledWith({
text: 'Queued request',
pendingSchedule: { type: 'absolute', ms: scheduledAt },
})
expect(getQueuedEditRecovery('session-1')).toBeNull()
})
it('keeps a remounted session busy until its queued-edit recovery is consumed', async () => {
const raf = installManualAnimationFrames()
const scheduledAt = Date.now() + 60_000
const { unmount } = renderQueuedMessage(scheduledAt)
fireEvent.click(screen.getByRole('button', { name: 'Edit queued message' }))
unmount()
const { onEdit } = renderQueuedMessage(scheduledAt)
await resolveCancel({ status: 'cancelled' })
// The mutation token has ended, but the unconsumed recovery keeps Q2 busy.
expect(screen.getByRole('button', { name: 'Edit queued message' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'Cancel queued message' }))
expect(mocks.mutateAsync).toHaveBeenCalledTimes(1)
raf.flushPending()
await waitFor(() => expect(screen.getByRole('button', { name: 'Edit queued message' })).not.toBeDisabled())
expect(onEdit).toHaveBeenCalledWith({
text: 'Queued request',
pendingSchedule: { type: 'absolute', ms: scheduledAt },
})
})
it('ignores overlapping and disposed recovery animation callbacks, then allows a fresh remount to consume', async () => {
const raf = installManualAnimationFrames()
const scheduledAt = Date.now() + 60_000
const first = renderQueuedMessage(scheduledAt)
fireEvent.click(screen.getByRole('button', { name: 'Edit queued message' }))
first.unmount()
const second = renderQueuedMessage(scheduledAt)
await resolveCancel({ status: 'cancelled' })
second.unmount()
raf.flushHistory()
expect(mocks.composerSetText).not.toHaveBeenCalled()
expect(second.onEdit).not.toHaveBeenCalled()
expect(getQueuedEditRecovery('session-1')).not.toBeNull()
const third = renderQueuedMessage(scheduledAt)
raf.flushPending()
await waitFor(() => expect(mocks.composerSetText).toHaveBeenCalledWith('Queued request'))
expect(third.onEdit).toHaveBeenCalledWith({
text: 'Queued request',
pendingSchedule: { type: 'absolute', ms: scheduledAt },
})
expect(getQueuedEditRecovery('session-1')).toBeNull()
})
it('clears a conflicting recovery once and never restores it after a later composer clear', async () => {
const scheduledAt = Date.now() + 60_000
const { unmount } = renderQueuedMessage(scheduledAt)
fireEvent.click(screen.getByRole('button', { name: 'Edit queued message' }))
unmount()
await resolveCancel({ status: 'cancelled' })
mocks.composerText = 'Current draft wins'
const { onEdit, rerender } = renderQueuedMessage(scheduledAt)
await waitFor(() => expect(mocks.addToast).toHaveBeenCalledWith({
title: 'queuedMessages.editCurrentDraftKept',
body: '',
sessionId: 'session-1',
url: window.location.href,
}))
expect(getQueuedEditRecovery('session-1')).toBeNull()
mocks.composerText = ''
rerender(null, 0)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(mocks.composerSetText).not.toHaveBeenCalled()
expect(onEdit).not.toHaveBeenCalled()
})
it('keeps the already-invoked toast behavior', async () => {
const { onEdit } = renderQueuedMessage()
fireEvent.click(screen.getByRole('button', { name: 'Edit queued message' }))
await resolveCancel({ status: 'invoked' })
expect(mocks.composerSetText).not.toHaveBeenCalled()
expect(onEdit).not.toHaveBeenCalled()
expect(mocks.addToast).toHaveBeenCalledWith({
title: 'queuedMessages.editAlreadyInvoked',
body: '',
sessionId: 'session-1',
url: window.location.href,
})
})
it('does not restore an edit when cancel fails', async () => {
const { onEdit } = renderQueuedMessage(Date.now() + 60_000)
fireEvent.click(screen.getByRole('button', { name: 'Edit queued message' }))
await act(async () => {
mocks.rejectCancel?.(new Error('network failed'))
await Promise.resolve()
})
expect(mocks.composerSetText).not.toHaveBeenCalled()
expect(onEdit).not.toHaveBeenCalled()
})
})
/**
* Unit tests for computeCanCancel — the race guard that prevents sending
@@ -1,5 +1,5 @@
import { useAui } from '@assistant-ui/react'
import { useCallback, useMemo, useSyncExternalStore } from 'react'
import { useAui, useAuiState } from '@assistant-ui/react'
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
import type { ApiClient } from '@/api/client'
import { getMessageWindowState, subscribeMessageWindow } from '@/lib/message-window-store'
import { isQueuedForInvocation } from '@/lib/messages'
@@ -11,6 +11,16 @@ import { useTranslation } from '@/lib/use-translation'
import { useToast } from '@/lib/toast-context'
import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
import { formatScheduledTime } from '@/lib/scheduledTime'
import {
beginQueuedOperation,
clearQueuedEditRecovery,
endQueuedOperation,
getQueuedEditRecovery,
isQueuedOperationPending,
saveQueuedEditRecovery,
subscribeQueuedEditRecovery,
subscribeQueuedOperation,
} from '@/lib/queued-edit-recovery'
function ClockIcon() {
return (
@@ -112,6 +122,19 @@ export function computeEditPendingSchedule(
return { type: 'absolute', ms: scheduledAt }
}
function pendingSchedulesEqual(a: PendingSchedule | null, b: PendingSchedule | null): boolean {
if (a === b) return true
if (a === null || b === null) return false
switch (a.type) {
case 'preset':
return b.type === 'preset' && a.preset === b.preset
case 'absolute':
return b.type === 'absolute' && a.ms === b.ms
}
const exhaustive: never = a
return exhaustive
}
/**
* Determines whether the user can cancel or edit a queued message.
*
@@ -150,10 +173,16 @@ export function computeCanCancel({
export function QueuedMessagesBar({
sessionId,
api,
pendingSchedule,
pendingScheduleRevision,
onEdit,
}: {
sessionId: string
api: ApiClient | null
/** Current composer schedule, used only to guard an asynchronous edit restore. */
pendingSchedule: PendingSchedule | null
/** Monotonic per-session revision; schedule selections win over an async edit restore. */
pendingScheduleRevision: number
/**
* Called when the user clicks Edit on a queued message.
* The parent should restore `text` into the composer and `pendingSchedule` into the schedule state.
@@ -163,9 +192,108 @@ export function QueuedMessagesBar({
}) {
const queued = useQueuedMessages(sessionId)
const assistantApi = useAui()
const composerText = useAuiState((state) => state.composer.text)
const cancelMutation = useCancelQueuedMessage(api)
const { t } = useTranslation()
const { addToast } = useToast()
const pendingScheduleRef = useRef(pendingSchedule)
const pendingScheduleRevisionRef = useRef(pendingScheduleRevision)
const composerTextRef = useRef(composerText)
const onEditRef = useRef(onEdit)
const mountedRef = useRef(true)
const attemptedRecoveryIdsRef = useRef(new Set<string>())
// onSuccess runs after the cancel request completes, so it must read the
// newest schedule rather than the render that initiated the request.
pendingScheduleRef.current = pendingSchedule
pendingScheduleRevisionRef.current = pendingScheduleRevision
composerTextRef.current = composerText
onEditRef.current = onEdit
const queuedOperationPending = useSyncExternalStore(
useCallback((listener) => subscribeQueuedOperation(sessionId, listener), [sessionId]),
useCallback(() => isQueuedOperationPending(sessionId), [sessionId]),
() => false,
)
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
const restoreQueuedEditRecovery = useCallback(() => {
const recovery = getQueuedEditRecovery(sessionId)
if (!recovery || attemptedRecoveryIdsRef.current.has(recovery.id)) return
attemptedRecoveryIdsRef.current.add(recovery.id)
const currentText = assistantApi.composer().getState().text
// A new session starts at revision 0. Any schedule interaction, even
// select-then-clear back to null, increments it and wins over recovery.
const textCompatible = currentText === recovery.composerTextAtEdit
const scheduleCompatible = pendingScheduleRevisionRef.current === 0
if (!textCompatible || !scheduleCompatible) {
if (mountedRef.current) {
addToast({
title: t('queuedMessages.editCurrentDraftKept'),
body: '',
sessionId,
url: window.location.href,
})
}
clearQueuedEditRecovery(sessionId)
return
}
if (recovery.text) {
assistantApi.composer().setText(recovery.text)
}
onEditRef.current?.({ text: recovery.text, pendingSchedule: recovery.pendingSchedule })
clearQueuedEditRecovery(sessionId)
}, [addToast, assistantApi, sessionId, t])
useEffect(() => {
let disposed = false
let generation = 0
const scheduledHandles = new Set<number>()
const scheduleFrame = (callback: FrameRequestCallback): number => {
if (typeof requestAnimationFrame === 'function') return requestAnimationFrame(callback)
return window.setTimeout(() => callback(Date.now()), 0)
}
const cancelFrame = (handle: number): void => {
if (typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(handle)
} else {
window.clearTimeout(handle)
}
}
const attemptAfterComposerDraft = () => {
const attemptGeneration = ++generation
const scheduleAttemptFrame = (callback: FrameRequestCallback) => {
let handle = 0
handle = scheduleFrame((timestamp) => {
scheduledHandles.delete(handle)
if (disposed || attemptGeneration !== generation) return
callback(timestamp)
})
scheduledHandles.add(handle)
}
scheduleAttemptFrame(() => {
scheduleAttemptFrame(restoreQueuedEditRecovery)
})
}
attemptAfterComposerDraft()
const unsubscribe = subscribeQueuedEditRecovery(sessionId, attemptAfterComposerDraft)
return () => {
disposed = true
generation++
unsubscribe()
for (const handle of scheduledHandles) {
cancelFrame(handle)
}
scheduledHandles.clear()
}
}, [restoreQueuedEditRecovery, sessionId])
if (queued.length === 0) {
return null
@@ -192,54 +320,99 @@ export function QueuedMessagesBar({
const editText = getQueuedMessageEditText(preview)
const hasAttachments = attachmentNames.length > 0
const localId = msg.localId ?? msg.id
const isPending = cancelMutation.isPending && cancelMutation.variables?.localId === localId
const isPending = cancelMutation.isPending || queuedOperationPending
const canCancel = computeCanCancel({ id: msg.id, localId: msg.localId, isPending })
const handleCancel = () => {
if (!canCancel) return
cancelMutation.mutate({
const token = beginQueuedOperation(sessionId)
if (!token) return
void cancelMutation.mutateAsync({
sessionId,
messageId: msg.id,
localId,
snapshot: msg,
}).catch(() => {
// useCancelQueuedMessage restores the optimistic row and gives haptic feedback.
}).finally(() => {
endQueuedOperation(sessionId, token)
})
}
const handleEdit = () => {
const handleEdit = async () => {
if (!canCancel) return
// Edit = cancel + restore composer (text + schedule).
// Works the same for immediate-queued and future-scheduled messages.
const restoredPendingSchedule = computeEditPendingSchedule(msg.scheduledAt, Date.now())
// The cancel request is asynchronous. Keep the exact composer text from the
// click so a newer draft or schedule is never replaced when success arrives.
const composerTextAtEdit = assistantApi.composer().getState().text
const pendingScheduleAtEdit = pendingScheduleRef.current
const pendingScheduleRevisionAtEdit = pendingScheduleRevisionRef.current
const token = beginQueuedOperation(sessionId)
if (!token) return
cancelMutation.mutate(
{
try {
const result = await cancelMutation.mutateAsync({
sessionId,
messageId: msg.id,
localId,
snapshot: msg,
},
{
onSuccess: (result) => {
// Race guard: if the agent already consumed this message, skip prefill
// and inform the user so they aren't confused by the row disappearing.
if (result.status === 'invoked') {
addToast({
title: t('queuedMessages.editAlreadyInvoked'),
body: '',
sessionId,
url: window.location.href,
})
return
}
// Restore text into composer
if (editText) {
assistantApi.composer().setText(editText)
}
// Restore schedule via parent callback (if provided)
onEdit?.({ text: editText, pendingSchedule: restoredPendingSchedule })
},
})
// Race guard: if the agent already consumed this message, skip prefill
// and inform the user so they aren't confused by the row disappearing.
if (result.status === 'invoked') {
if (mountedRef.current) {
addToast({
title: t('queuedMessages.editAlreadyInvoked'),
body: '',
sessionId,
url: window.location.href,
})
}
return
}
)
const currentText = mountedRef.current
? assistantApi.composer().getState().text
: composerTextRef.current
const composerChanged = currentText !== composerTextAtEdit
const scheduleChanged = pendingScheduleRevisionRef.current !== pendingScheduleRevisionAtEdit
|| !pendingSchedulesEqual(pendingScheduleRef.current, pendingScheduleAtEdit)
// Restore text and schedule as one unit. If either changed while the
// cancel was pending, the user's newer composer state wins.
if (composerChanged || scheduleChanged) {
if (mountedRef.current) {
addToast({
title: t('queuedMessages.editCurrentDraftKept'),
body: '',
sessionId,
url: window.location.href,
})
}
return
}
if (!mountedRef.current) {
// The original composer is gone. Persist both values and notify a
// same-session remount so the result is not lost or delayed until a
// later navigation cycle.
saveQueuedEditRecovery(sessionId, {
text: editText,
pendingSchedule: restoredPendingSchedule,
composerTextAtEdit,
pendingScheduleAtEdit,
})
return
}
if (editText) {
assistantApi.composer().setText(editText)
}
onEdit?.({ text: editText, pendingSchedule: restoredPendingSchedule })
} catch {
// useCancelQueuedMessage restores the optimistic row and gives haptic feedback.
} finally {
endQueuedOperation(sessionId, token)
}
}
const canEdit = canCancel
+15 -8
View File
@@ -1232,6 +1232,11 @@ function SessionChatInner(props: SessionChatProps) {
// The ref is read at send time; resolvePendingSchedule converts it to an
// absolute epoch-ms using Date.now() at that moment (send-time base for presets).
const [pendingSchedule, setPendingSchedule] = useState<PendingSchedule | null>(null)
const [pendingScheduleRevision, setPendingScheduleRevision] = useState(0)
const updatePendingSchedule = useCallback((next: PendingSchedule | null) => {
setPendingSchedule(next)
setPendingScheduleRevision((revision) => revision + 1)
}, [])
const pendingScheduleRef = useRef<PendingSchedule | null>(null)
// Keep render ref in sync so onNew can snapshot at send time
pendingScheduleRef.current = pendingSchedule
@@ -1247,12 +1252,12 @@ function SessionChatInner(props: SessionChatProps) {
const ms = (pendingSchedule as Extract<PendingSchedule, { type: 'absolute' }>).ms
const remaining = ms - Date.now()
if (remaining <= 0) {
setPendingSchedule(null)
updatePendingSchedule(null)
return
}
const timer = setTimeout(() => setPendingSchedule(null), remaining)
const timer = setTimeout(() => updatePendingSchedule(null), remaining)
return () => clearTimeout(timer)
}, [pendingSchedule])
}, [pendingSchedule, updatePendingSchedule])
const handleSend = useCallback(async (text: string, attachments?: AttachmentMetadata[], scheduledAt?: number | null) => {
// Route through the scratchlist-aware wrapper. When scratchlistMode
@@ -1277,10 +1282,10 @@ function SessionChatInner(props: SessionChatProps) {
// its own send path). Schedule clear / forced scroll only
// matter for chat sends; scratchlist adds don't have a
// schedule and shouldn't move the chat viewport.
setPendingSchedule(null)
updatePendingSchedule(null)
setForceScrollToken((token) => token + 1)
}
}, [onSendForComposer, scratchlistMode])
}, [onSendForComposer, scratchlistMode, updatePendingSchedule])
const attachmentAdapter = useMemo(() => {
if (!props.session.active) {
@@ -1424,9 +1429,11 @@ function SessionChatInner(props: SessionChatProps) {
<QueuedMessagesBar
sessionId={props.session.id}
api={props.api}
pendingSchedule={pendingSchedule}
pendingScheduleRevision={pendingScheduleRevision}
onEdit={({ pendingSchedule: restored }) => {
// Restore the schedule so the clock button re-activates
setPendingSchedule(restored)
updatePendingSchedule(restored)
}}
/>
</div>
@@ -1437,8 +1444,8 @@ function SessionChatInner(props: SessionChatProps) {
resolveSessionMentionTooltip={resolveSessionMentionTooltip}
disabled={props.isSending}
pendingSchedule={pendingSchedule}
onSchedule={setPendingSchedule}
onClearSchedule={() => setPendingSchedule(null)}
onSchedule={updatePendingSchedule}
onClearSchedule={() => updatePendingSchedule(null)}
permissionMode={props.session.permissionMode}
collaborationMode={codexCollaborationModeSupported ? props.session.collaborationMode : undefined}
model={props.session.model}
+1
View File
@@ -530,6 +530,7 @@ export default {
'composer.scheduleErrorTooFar': 'Maximum schedule time is 7 days.',
'queuedMessages.scheduledFor': 'Scheduled for {time}',
'queuedMessages.editAlreadyInvoked': "Message already sent — it can't be edited",
'queuedMessages.editCurrentDraftKept': 'Queued message cancelled — current draft and schedule were kept.',
// Scratchlist (per-session workbench, issue #11)
'scratchlist.title': 'Scratchlist',
+1
View File
@@ -534,6 +534,7 @@ export default {
'composer.scheduleErrorTooFar': '最多只能定时 7 天。',
'queuedMessages.scheduledFor': '定时发送: {time}',
'queuedMessages.editAlreadyInvoked': '消息已发送,无法编辑',
'queuedMessages.editCurrentDraftKept': '队列消息已取消,已保留当前草稿和定时设置。',
// Scratchlist (per-session workbench, issue #11)
'scratchlist.title': '草稿夹',
+103
View File
@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const STORAGE_KEY = 'hapi:queued-edit-recovery'
async function loadStore() {
return await import('./queued-edit-recovery')
}
beforeEach(() => {
sessionStorage.clear()
vi.resetModules()
})
describe('queued-edit-recovery', () => {
it('drops invalid persisted schedules and rewrites the sanitized cache', async () => {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
badPreset: {
id: 'bad-preset',
text: 'text',
pendingSchedule: { type: 'preset', preset: '+99m' },
composerTextAtEdit: '',
pendingScheduleAtEdit: null,
},
badAbsolute: {
id: 'bad-absolute',
text: 'text',
pendingSchedule: { type: 'absolute', ms: Infinity },
composerTextAtEdit: '',
pendingScheduleAtEdit: null,
},
}))
const store = await loadStore()
expect(store.getQueuedEditRecovery('badPreset')).toBeNull()
expect(store.getQueuedEditRecovery('badAbsolute')).toBeNull()
expect(sessionStorage.getItem(STORAGE_KEY)).toBe('{}')
})
it('notifies session listeners and returns isolated recovery copies', async () => {
const store = await loadStore()
const listener = vi.fn()
const unsubscribe = store.subscribeQueuedEditRecovery('session-1', listener)
store.saveQueuedEditRecovery('session-1', {
text: 'queued edit',
pendingSchedule: { type: 'preset', preset: '+30m' },
composerTextAtEdit: 'before edit',
pendingScheduleAtEdit: null,
})
expect(listener).toHaveBeenCalledTimes(1)
expect(store.isQueuedOperationPending('session-1')).toBe(true)
expect(store.beginQueuedOperation('session-1')).toBeNull()
const first = store.getQueuedEditRecovery('session-1')!
first.pendingSchedule = null
expect(store.getQueuedEditRecovery('session-1')?.pendingSchedule).toEqual({ type: 'preset', preset: '+30m' })
unsubscribe()
store.saveQueuedEditRecovery('session-1', {
text: 'latest edit',
pendingSchedule: null,
composerTextAtEdit: '',
pendingScheduleAtEdit: null,
})
expect(listener).toHaveBeenCalledTimes(1)
})
it('keeps at most one pending operation per session and token-protects newer work', async () => {
const store = await loadStore()
const listener = vi.fn()
const unsubscribe = store.subscribeQueuedOperation('session-1', listener)
const first = store.beginQueuedOperation('session-1')
expect(first).not.toBeNull()
expect(store.isQueuedOperationPending('session-1')).toBe(true)
expect(store.beginQueuedOperation('session-1')).toBeNull()
expect(listener).toHaveBeenCalledTimes(1)
store.endQueuedOperation('session-1', first!)
const second = store.beginQueuedOperation('session-1')!
store.endQueuedOperation('session-1', first!)
expect(store.isQueuedOperationPending('session-1')).toBe(true)
store.endQueuedOperation('session-1', second)
expect(store.isQueuedOperationPending('session-1')).toBe(false)
expect(listener).toHaveBeenCalledTimes(4)
unsubscribe()
})
it('retains every unconsumed recovery beyond the former session cap', async () => {
const store = await loadStore()
for (let index = 0; index <= 50; index++) {
store.saveQueuedEditRecovery(`session-${index}`, {
text: String(index),
pendingSchedule: null,
composerTextAtEdit: '',
pendingScheduleAtEdit: null,
})
}
expect(store.getQueuedEditRecovery('session-0')?.text).toBe('0')
expect(store.getQueuedEditRecovery('session-50')?.text).toBe('50')
})
})
+207
View File
@@ -0,0 +1,207 @@
import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
const STORAGE_KEY = 'hapi:queued-edit-recovery'
export type QueuedEditRecovery = {
id: string
text: string
pendingSchedule: PendingSchedule | null
composerTextAtEdit: string
pendingScheduleAtEdit: PendingSchedule | null
}
export type QueuedEditRecoveryInput = Omit<QueuedEditRecovery, 'id'>
type RecoveryMap = Record<string, QueuedEditRecovery>
export type QueuedOperationToken = symbol
let cache: RecoveryMap | null = null
let recoverySequence = 0
const listeners = new Map<string, Set<() => void>>()
const pendingOperationTokens = new Map<string, QueuedOperationToken>()
const pendingOperationListeners = new Map<string, Set<() => void>>()
const PRESETS = new Set<Extract<PendingSchedule, { type: 'preset' }>['preset']>([
'+5m',
'+30m',
'+1h',
'+4h',
])
function clonePendingSchedule(schedule: PendingSchedule | null): PendingSchedule | null {
if (schedule === null) return null
return schedule.type === 'preset'
? { type: 'preset', preset: schedule.preset }
: { type: 'absolute', ms: schedule.ms }
}
function isPendingSchedule(value: unknown): value is PendingSchedule {
if (!value || typeof value !== 'object') return false
const record = value as Record<string, unknown>
return (record.type === 'preset' && typeof record.preset === 'string' && PRESETS.has(record.preset as never))
|| (record.type === 'absolute' && typeof record.ms === 'number' && Number.isFinite(record.ms))
}
function parsePendingSchedule(value: unknown): PendingSchedule | null | undefined {
if (value === null) return null
if (!isPendingSchedule(value)) return undefined
return clonePendingSchedule(value)
}
function hydrate(): RecoveryMap {
if (cache) return cache
if (typeof window === 'undefined') {
cache = {}
return cache
}
try {
const raw = sessionStorage.getItem(STORAGE_KEY)
if (!raw) {
cache = {}
return cache
}
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object') {
cache = {}
return cache
}
const result: RecoveryMap = {}
let sanitized = false
for (const [sessionId, value] of Object.entries(parsed as Record<string, unknown>)) {
if (!sessionId.trim() || !value || typeof value !== 'object') {
sanitized = true
continue
}
const record = value as Record<string, unknown>
const pendingSchedule = parsePendingSchedule(record.pendingSchedule)
const pendingScheduleAtEdit = parsePendingSchedule(record.pendingScheduleAtEdit)
if (
typeof record.id !== 'string'
||
typeof record.text !== 'string'
|| typeof record.composerTextAtEdit !== 'string'
|| pendingSchedule === undefined
|| pendingScheduleAtEdit === undefined
) {
sanitized = true
continue
}
result[sessionId] = {
id: record.id,
text: record.text,
pendingSchedule,
composerTextAtEdit: record.composerTextAtEdit,
pendingScheduleAtEdit,
}
}
cache = result
if (sanitized) {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(result))
} catch {
// Keep the in-memory sanitized cache even if persistence fails.
}
}
return cache
} catch {
cache = {}
return cache
}
}
function persist(): void {
if (typeof window === 'undefined') return
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(hydrate()))
} catch {
// Recovery is best effort when sessionStorage is unavailable or full.
}
}
function notify(sessionId: string): void {
for (const listener of listeners.get(sessionId) ?? []) {
listener()
}
}
function notifyPendingOperation(sessionId: string): void {
for (const listener of pendingOperationListeners.get(sessionId) ?? []) {
listener()
}
}
export function getQueuedEditRecovery(sessionId: string): QueuedEditRecovery | null {
const recovery = hydrate()[sessionId]
if (!recovery) return null
return {
id: recovery.id,
text: recovery.text,
pendingSchedule: clonePendingSchedule(recovery.pendingSchedule),
composerTextAtEdit: recovery.composerTextAtEdit,
pendingScheduleAtEdit: clonePendingSchedule(recovery.pendingScheduleAtEdit),
}
}
export function saveQueuedEditRecovery(sessionId: string, recovery: QueuedEditRecoveryInput): void {
const recoveries = hydrate()
delete recoveries[sessionId]
recoveries[sessionId] = {
id: `${Date.now()}:${++recoverySequence}`,
text: recovery.text,
pendingSchedule: clonePendingSchedule(recovery.pendingSchedule),
composerTextAtEdit: recovery.composerTextAtEdit,
pendingScheduleAtEdit: clonePendingSchedule(recovery.pendingScheduleAtEdit),
}
persist()
notify(sessionId)
notifyPendingOperation(sessionId)
}
export function clearQueuedEditRecovery(sessionId: string): void {
const recoveries = hydrate()
if (!recoveries[sessionId]) return
delete recoveries[sessionId]
persist()
notifyPendingOperation(sessionId)
}
export function subscribeQueuedEditRecovery(sessionId: string, listener: () => void): () => void {
const sessionListeners = listeners.get(sessionId) ?? new Set<() => void>()
sessionListeners.add(listener)
listeners.set(sessionId, sessionListeners)
return () => {
sessionListeners.delete(listener)
if (sessionListeners.size === 0) listeners.delete(sessionId)
}
}
/**
* Starts the single queued-message operation allowed for a session.
* The opaque token prevents an older completion from releasing a newer one.
*/
export function beginQueuedOperation(sessionId: string): QueuedOperationToken | null {
if (pendingOperationTokens.has(sessionId) || Boolean(hydrate()[sessionId])) return null
const token = Symbol(`queued-operation:${sessionId}`)
pendingOperationTokens.set(sessionId, token)
notifyPendingOperation(sessionId)
return token
}
export function endQueuedOperation(sessionId: string, token: QueuedOperationToken): void {
if (pendingOperationTokens.get(sessionId) !== token) return
pendingOperationTokens.delete(sessionId)
notifyPendingOperation(sessionId)
}
export function isQueuedOperationPending(sessionId: string): boolean {
return pendingOperationTokens.has(sessionId) || Boolean(hydrate()[sessionId])
}
export function subscribeQueuedOperation(sessionId: string, listener: () => void): () => void {
const sessionListeners = pendingOperationListeners.get(sessionId) ?? new Set<() => void>()
sessionListeners.add(listener)
pendingOperationListeners.set(sessionId, sessionListeners)
return () => {
sessionListeners.delete(listener)
if (sessionListeners.size === 0) pendingOperationListeners.delete(sessionId)
}
}