feat(hub,web): support scheduling messages for future delivery (#590)

This commit is contained in:
Junmo Kim
2026-05-18 09:09:17 +08:00
committed by GitHub
parent 2e96992dd4
commit b2a30c2e39
33 changed files with 3082 additions and 153 deletions
@@ -117,4 +117,121 @@ describe('useSendMessage', () => {
expect(onBlocked).toHaveBeenCalledWith('no-api')
expect(onSuccess).not.toHaveBeenCalled()
})
it('resolves true when the send is accepted', async () => {
const api = createMockApi()
const { result } = renderHook(
() => useSendMessage(api, 'session-A'),
{ wrapper: createWrapper() },
)
let acceptedPromise: Promise<boolean> | undefined
act(() => {
acceptedPromise = result.current.sendMessage('hello')
})
await expect(acceptedPromise!).resolves.toBe(true)
})
it('resolves false when blocked (no api) so the caller can preserve schedule state', async () => {
const onBlocked = vi.fn()
const { result } = renderHook(
() => useSendMessage(null, 'session-A', { onBlocked }),
{ wrapper: createWrapper() },
)
let acceptedPromise: Promise<boolean> | undefined
act(() => {
acceptedPromise = result.current.sendMessage('hello')
})
await expect(acceptedPromise!).resolves.toBe(false)
expect(onBlocked).toHaveBeenCalledWith('no-api')
})
it('resolves false when blocked (no session)', async () => {
const api = createMockApi()
const { result } = renderHook(
() => useSendMessage(api, null),
{ wrapper: createWrapper() },
)
let acceptedPromise: Promise<boolean> | undefined
act(() => {
acceptedPromise = result.current.sendMessage('hello')
})
await expect(acceptedPromise!).resolves.toBe(false)
})
it('resolves false when resolveSessionId throws (inactive-session resume failure)', async () => {
const api = createMockApi()
const resumeError = new Error('resume failed')
const { result } = renderHook(
() => useSendMessage(api, 'session-A', {
resolveSessionId: async () => { throw resumeError },
onSessionResolved: vi.fn(),
}),
{ wrapper: createWrapper() },
)
let acceptedPromise: Promise<boolean> | undefined
act(() => {
acceptedPromise = result.current.sendMessage('hello')
})
await expect(acceptedPromise!).resolves.toBe(false)
})
it('resolves true after async resolveSessionId succeeds and mutation starts', async () => {
const api = createMockApi()
const { result } = renderHook(
() => useSendMessage(api, 'session-original', {
resolveSessionId: async () => 'session-resolved',
onSessionResolved: vi.fn(),
}),
{ wrapper: createWrapper() },
)
let acceptedPromise: Promise<boolean> | undefined
act(() => {
acceptedPromise = result.current.sendMessage('hello')
})
await expect(acceptedPromise!).resolves.toBe(true)
})
it('preserves scheduledAt when retrying a failed scheduled message', async () => {
const sendMock = vi.fn(async () => {})
const api = createMockApi(sendMock)
const scheduledAt = Date.now() + 5 * 60_000
const { getMessageWindowState } = await import('@/lib/message-window-store')
vi.mocked(getMessageWindowState).mockReturnValueOnce({
messages: [],
pending: [{
id: 'local-retry-1',
seq: null,
localId: 'local-retry-1',
content: { role: 'user', content: { type: 'text', text: 'hi later' } },
createdAt: 1_000,
invokedAt: null,
scheduledAt,
status: 'failed',
originalText: 'hi later',
} as never],
} as never)
const { result } = renderHook(
() => useSendMessage(api, 'session-A'),
{ wrapper: createWrapper() },
)
act(() => {
result.current.retryMessage('local-retry-1')
})
await waitFor(() => {
expect(sendMock).toHaveBeenCalled()
})
// api.sendMessage(sessionId, text, localId, attachments, scheduledAt)
expect(sendMock).toHaveBeenCalledWith(
'session-A',
'hi later',
'local-retry-1',
undefined,
scheduledAt,
)
})
})
+48 -38
View File
@@ -16,6 +16,7 @@ type SendMessageInput = {
localId: string
createdAt: number
attachments?: AttachmentMetadata[]
scheduledAt?: number | null
}
type BlockedReason = 'no-api' | 'no-session' | 'pending'
@@ -48,6 +49,7 @@ function createOptimisticMessage(input: SendMessageInput, status: 'queued' | 'se
// response that omits the field entirely (`undefined`) is treated as
// already-invoked and stays in the thread, not the floating bar.
invokedAt: null,
scheduledAt: input.scheduledAt ?? null,
status,
originalText: input.text,
}
@@ -72,8 +74,14 @@ export function useSendMessage(
sessionId: string | null,
options?: UseSendMessageOptions
): {
sendMessage: (text: string, attachments?: AttachmentMetadata[]) => void
retryMessage: (localId: string) => void
// Resolves true when a mutation was actually started, false when the call was
// rejected pre-mutation (no-api / no-session / pending) OR the async
// resolveSessionId step threw. Async is required because inactive-session
// resume happens before mutation.mutate(), and a sync `true` would let the
// caller clear UI state (e.g. pendingSchedule) before knowing whether
// resume succeeded — see SessionChat.handleSend.
sendMessage: (text: string, attachments?: AttachmentMetadata[], scheduledAt?: number | null) => Promise<boolean>
retryMessage: (localId: string) => boolean
isSending: boolean
} {
const { haptic } = usePlatform()
@@ -87,7 +95,7 @@ export function useSendMessage(
if (!api) {
throw new Error('API unavailable')
}
await api.sendMessage(input.sessionId, input.text, input.localId, input.attachments)
await api.sendMessage(input.sessionId, input.text, input.localId, input.attachments, input.scheduledAt)
},
onMutate: async (input) => {
const status = isSessionThinkingRef.current ? 'queued' as const : 'sending' as const
@@ -109,71 +117,71 @@ export function useSendMessage(
},
})
const sendMessage = (text: string, attachments?: AttachmentMetadata[]) => {
const sendMessage = async (text: string, attachments?: AttachmentMetadata[], scheduledAt?: number | null): Promise<boolean> => {
if (!api) {
options?.onBlocked?.('no-api')
haptic.notification('error')
return
return false
}
if (!sessionId) {
options?.onBlocked?.('no-session')
haptic.notification('error')
return
return false
}
if (mutation.isPending || resolveGuardRef.current) {
options?.onBlocked?.('pending')
return
return false
}
const localId = makeClientSideId('local')
const createdAt = Date.now()
void (async () => {
let targetSessionId = sessionId
if (options?.resolveSessionId) {
resolveGuardRef.current = true
setIsResolving(true)
try {
const resolved = await options.resolveSessionId(sessionId)
if (resolved && resolved !== sessionId) {
options.onSessionResolved?.(resolved)
targetSessionId = resolved
}
} catch (error) {
haptic.notification('error')
console.error('Failed to resolve session before send:', error)
return
} finally {
resolveGuardRef.current = false
setIsResolving(false)
let targetSessionId = sessionId
if (options?.resolveSessionId) {
resolveGuardRef.current = true
setIsResolving(true)
try {
const resolved = await options.resolveSessionId(sessionId)
if (resolved && resolved !== sessionId) {
options.onSessionResolved?.(resolved)
targetSessionId = resolved
}
} catch (error) {
haptic.notification('error')
console.error('Failed to resolve session before send:', error)
return false
} finally {
resolveGuardRef.current = false
setIsResolving(false)
}
mutation.mutate({
sessionId: targetSessionId,
text,
localId,
createdAt,
attachments,
})
})()
}
mutation.mutate({
sessionId: targetSessionId,
text,
localId,
createdAt,
attachments,
scheduledAt,
})
return true
}
const retryMessage = (localId: string) => {
const retryMessage = (localId: string): boolean => {
if (!api) {
options?.onBlocked?.('no-api')
haptic.notification('error')
return
return false
}
if (!sessionId) {
options?.onBlocked?.('no-session')
haptic.notification('error')
return
return false
}
if (mutation.isPending || resolveGuardRef.current) {
options?.onBlocked?.('pending')
return
return false
}
const message = findMessageByLocalId(sessionId, localId)
if (!message?.originalText) return
if (!message?.originalText) return false
updateMessageStatus(sessionId, localId, 'sending')
@@ -182,7 +190,9 @@ export function useSendMessage(
text: message.originalText,
localId,
createdAt: message.createdAt,
scheduledAt: message.scheduledAt ?? null,
})
return true
}
return {