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
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)
}
}