mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(web): preserve queued edits across cancellation (#1324)
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -534,6 +534,7 @@ export default {
|
||||
'composer.scheduleErrorTooFar': '最多只能定时 7 天。',
|
||||
'queuedMessages.scheduledFor': '定时发送: {time}',
|
||||
'queuedMessages.editAlreadyInvoked': '消息已发送,无法编辑',
|
||||
'queuedMessages.editCurrentDraftKept': '队列消息已取消,已保留当前草稿和定时设置。',
|
||||
|
||||
// Scratchlist (per-session workbench, issue #11)
|
||||
'scratchlist.title': '草稿夹',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user