refactor app draft status controls into input area

This commit is contained in:
Mohamed Boudra
2026-03-05 15:07:56 +07:00
parent 8371ea0c7d
commit 885b575d14
14 changed files with 1370 additions and 894 deletions
@@ -0,0 +1,239 @@
import { useCallback, useMemo, useReducer } from 'react'
import { useCreateFlowStore } from '@/stores/create-flow-store'
import { generateMessageId, type StreamItem, type UserMessageImageAttachment } from '@/types/stream'
const EMPTY_STREAM_ITEMS: StreamItem[] = []
type CreateAttempt = {
clientMessageId: string
text: string
timestamp: Date
images?: UserMessageImageAttachment[]
}
type DraftAgentMachineState =
| { tag: 'draft'; promptText: string; errorMessage: string }
| { tag: 'creating'; attempt: CreateAttempt }
type DraftAgentMachineEvent =
| { type: 'DRAFT_SET_PROMPT'; text: string }
| { type: 'DRAFT_SET_ERROR'; message: string }
| { type: 'SUBMIT'; attempt: CreateAttempt }
| { type: 'CREATE_FAILED'; message: string }
function assertNever(value: never): never {
throw new Error(`Unhandled state: ${JSON.stringify(value)}`)
}
function reducer(state: DraftAgentMachineState, event: DraftAgentMachineEvent): DraftAgentMachineState {
switch (event.type) {
case 'DRAFT_SET_PROMPT': {
if (state.tag !== 'draft') {
return state
}
return { ...state, promptText: event.text }
}
case 'DRAFT_SET_ERROR': {
if (state.tag !== 'draft') {
return state
}
return { ...state, errorMessage: event.message }
}
case 'SUBMIT': {
return { tag: 'creating', attempt: event.attempt }
}
case 'CREATE_FAILED': {
if (state.tag !== 'creating') {
return state
}
return { tag: 'draft', promptText: state.attempt.text, errorMessage: event.message }
}
default:
return assertNever(event)
}
}
type CreateRequestResult<TCreateResult> = {
agentId: string | null
result: TCreateResult
}
type SubmitContext = {
text: string
images?: UserMessageImageAttachment[]
}
type CreateRequestContext = {
attempt: CreateAttempt
text: string
images?: UserMessageImageAttachment[]
}
interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
draftId: string
getPendingServerId: () => string | null
validateBeforeSubmit?: (ctx: SubmitContext) => string | null
onBeforeSubmit?: (ctx: CreateRequestContext) => void
onCreateStart?: () => void
createRequest: (ctx: CreateRequestContext) => Promise<CreateRequestResult<TCreateResult>>
buildDraftAgent: (attempt: CreateAttempt) => TDraftAgent
onCreateSuccess: (ctx: { result: TCreateResult; attempt: CreateAttempt }) => Promise<void> | void
onCreateError?: (error: Error) => void
}
export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
draftId,
getPendingServerId,
validateBeforeSubmit,
onBeforeSubmit,
onCreateStart,
createRequest,
buildDraftAgent,
onCreateSuccess,
onCreateError,
}: UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult>) {
const [machine, dispatch] = useReducer(reducer, {
tag: 'draft',
promptText: '',
errorMessage: '',
} as DraftAgentMachineState)
const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending)
const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId)
const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle)
const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear)
const promptValue = machine.tag === 'draft' ? machine.promptText : ''
const formErrorMessage = machine.tag === 'draft' ? machine.errorMessage : ''
const isSubmitting = machine.tag === 'creating'
const optimisticStreamItems = useMemo<StreamItem[]>(() => {
if (machine.tag !== 'creating') {
return EMPTY_STREAM_ITEMS
}
return [
{
kind: 'user_message',
id: machine.attempt.clientMessageId,
text: machine.attempt.text,
timestamp: machine.attempt.timestamp,
...(machine.attempt.images && machine.attempt.images.length > 0
? { images: machine.attempt.images }
: {}),
},
]
}, [machine])
const draftAgent = useMemo<TDraftAgent | null>(() => {
if (machine.tag !== 'creating') {
return null
}
return buildDraftAgent(machine.attempt)
}, [buildDraftAgent, machine])
const setPromptText = useCallback((text: string) => {
dispatch({ type: 'DRAFT_SET_PROMPT', text })
}, [])
const handleCreateFromInput = useCallback(
async ({ text, images }: SubmitContext) => {
if (isSubmitting) {
throw new Error('Already loading')
}
dispatch({ type: 'DRAFT_SET_ERROR', message: '' })
const trimmedPrompt = text.trim()
if (!trimmedPrompt) {
const error = new Error('Initial prompt is required')
dispatch({ type: 'DRAFT_SET_ERROR', message: error.message })
throw error
}
const validationError = validateBeforeSubmit?.({ text: trimmedPrompt, images })
if (validationError) {
const error = new Error(validationError)
dispatch({ type: 'DRAFT_SET_ERROR', message: validationError })
throw error
}
const pendingServerId = getPendingServerId()
if (!pendingServerId) {
const error = new Error('No host selected')
dispatch({ type: 'DRAFT_SET_ERROR', message: error.message })
throw error
}
const attempt: CreateAttempt = {
clientMessageId: generateMessageId(),
text: trimmedPrompt,
timestamp: new Date(),
...(images && images.length > 0 ? { images } : {}),
}
setPendingCreateAttempt({
draftId,
serverId: pendingServerId,
agentId: null,
clientMessageId: attempt.clientMessageId,
text: attempt.text,
timestamp: attempt.timestamp.getTime(),
...(attempt.images && attempt.images.length > 0 ? { images: attempt.images } : {}),
})
onBeforeSubmit?.({ attempt, text: trimmedPrompt, images })
dispatch({ type: 'SUBMIT', attempt })
onCreateStart?.()
try {
const createResult = await createRequest({
attempt,
text: trimmedPrompt,
images,
})
if (createResult.agentId) {
updatePendingAgentId({ draftId, agentId: createResult.agentId })
}
await onCreateSuccess({ result: createResult.result, attempt })
} catch (error) {
const resolved = error instanceof Error ? error : new Error('Failed to create agent')
dispatch({ type: 'CREATE_FAILED', message: resolved.message })
markPendingCreateLifecycle({ draftId, lifecycle: 'abandoned' })
clearPendingCreateAttempt({ draftId })
onCreateError?.(resolved)
throw error
}
},
[
clearPendingCreateAttempt,
createRequest,
draftId,
getPendingServerId,
isSubmitting,
markPendingCreateLifecycle,
onBeforeSubmit,
onCreateError,
onCreateStart,
onCreateSuccess,
setPendingCreateAttempt,
updatePendingAgentId,
validateBeforeSubmit,
]
)
return {
machine,
promptValue,
formErrorMessage,
isSubmitting,
optimisticStreamItems,
draftAgent,
setPromptText,
handleCreateFromInput,
}
}
export type { CreateAttempt as DraftCreateAttempt }