import { createEffect, createMemo, createSignal, createUniqueId, onCleanup, onMount, } from 'solid-js' import { ChatClient } from '@tanstack/ai-client' import { createChatDevtoolsBridge } from '@tanstack/ai-client/devtools' import type { ChatClientState, ResolvableChatInterrupt, ChatInterruptState, ChatResumeState, ConnectionStatus, InferredClientContext, QueuedMessage, SendMessageOptions, StructuredOutputPart, } from '@tanstack/ai-client' import type { AnyClientTool, InterruptDefinition, InferSchemaType, ModelMessage, RunAgentResumeItem, SchemaInput, StreamChunk, } from '@tanstack/ai' import type { DeepPartial, MultimodalContent, UIMessage, UseChatOptions, UseChatReturn, } from './types' const EMPTY_INTERRUPTS = Object.freeze([]) const EMPTY_INTERRUPT_ERRORS = Object.freeze([]) export function useChat< const TTools extends ReadonlyArray = any, TSchema extends SchemaInput | undefined = undefined, TContext = InferredClientContext, const TInterrupts extends ReadonlyArray< InterruptDefinition > = readonly [], >( options: UseChatOptions< TTools, TSchema, TContext, TInterrupts > = {} as UseChatOptions, ): UseChatReturn { // The hook's identity is its `threadId`. Reload with the same `threadId` // restores the same conversation. `hookId` is only a recreation key when no // `threadId` is given. It is never sent on the wire. const hookId = createUniqueId() const clientId = options.threadId ?? hookId const [messages, setMessages] = createSignal>>( options.initialMessages || [], ) const [isLoading, setIsLoading] = createSignal(false) const [hasOlderMessages, setHasOlderMessages] = createSignal(false) const [error, setError] = createSignal(undefined) const [status, setStatus] = createSignal('ready') const [isSubscribed, setIsSubscribed] = createSignal(false) const [connectionStatus, setConnectionStatus] = createSignal('disconnected') const [sessionGenerating, setSessionGenerating] = createSignal(false) const [queue, setQueue] = createSignal>([]) const [runId, setRunId] = createSignal(null) const [interruptState, setInterruptState] = createSignal< ChatInterruptState >({ interrupts: EMPTY_INTERRUPTS, pendingInterrupts: EMPTY_INTERRUPTS, interruptErrors: EMPTY_INTERRUPT_ERRORS, resuming: false, }) const syncResumeState = () => { setRunId(client().getCurrentRunId()) setInterruptState(client().getInterruptState()) } // Structured-output `partial` / `final` are derived from `messages` — // specifically from the structured-output part on the latest assistant // message (the one after the most recent user message). Per-turn parts // keep history coherent without a separate reset signal. type Partial = DeepPartial>> type Final = InferSchemaType> // Create ChatClient instance with callbacks to sync state. // Every user-provided callback is wrapped so the LATEST `options.xxx` value // is read at call time. Direct assignment would freeze the callback to the // reference we saw at creation; the wrapper lets reactive `options` or // in-place mutations propagate. When the user clears a callback (sets it to // undefined), `?.` no-ops. const client = createMemo(() => { // Build options with conditional spreads for fields whose source // type is `T | undefined` but the ChatClient target uses a strict // optional (`field?: T`) — `exactOptionalPropertyTypes` rejects // assigning `undefined` to those, so we omit the key when absent. const transport = options.connection ? { connection: options.connection } : { fetcher: options.fetcher } const instance = new ChatClient({ devtoolsBridgeFactory: createChatDevtoolsBridge, ...transport, ...(options.initialMessages !== undefined && { initialMessages: options.initialMessages, }), ...(typeof options.threadId === 'string' && options.persistence === true ? { persistence: true, threadId: options.threadId, ...(options.history !== undefined && { history: options.history, }), } : typeof options.threadId === 'string' && options.persistence ? { persistence: options.persistence, threadId: options.threadId, } : { ...(options.threadId !== undefined && { threadId: options.threadId, }), }), ...(options.initialResumeSnapshot !== undefined && { initialResumeSnapshot: options.initialResumeSnapshot, }), body: options.body, ...(options.forwardedProps !== undefined && { forwardedProps: options.forwardedProps, }), ...(options.byok !== undefined && { byok: options.byok }), byokProvider: () => options.byokProvider?.(), ...(options.context !== undefined && { context: options.context }), devtools: { ...options.devtools, framework: 'solid', hookName: 'useChat', outputKind: options.outputSchema ? 'structured' : 'chat', }, onResponse: (response) => options.onResponse?.(response), onChunk: (chunk: StreamChunk) => { options.onChunk?.(chunk) }, onFinish: (message) => { options.onFinish?.(message) }, onError: (err) => { options.onError?.(err) }, tools: options.tools, ...(options.interrupts !== undefined && { interrupts: options.interrupts, }), onCustomEvent: (eventType, data, context) => options.onCustomEvent?.(eventType, data, context), ...(options.streamProcessor !== undefined && { streamProcessor: options.streamProcessor, }), onMessagesChange: (newMessages: Array>) => { setMessages(newMessages) setHasOlderMessages(instance.getHasOlderMessages()) }, onLoadingChange: (newIsLoading: boolean) => { setIsLoading(newIsLoading) syncResumeState() }, onStatusChange: (newStatus: ChatClientState) => { setStatus(newStatus) }, onErrorChange: (newError: Error | undefined) => { setError(newError) }, onSubscriptionChange: (nextIsSubscribed: boolean) => { setIsSubscribed(nextIsSubscribed) }, onConnectionStatusChange: (nextStatus: ConnectionStatus) => { setConnectionStatus(nextStatus) }, onSessionGeneratingChange: (isGenerating: boolean) => { setSessionGenerating(isGenerating) }, ...(options.queue !== undefined && { queue: options.queue }), onQueueChange: (nextQueue: Array) => { setQueue(nextQueue) }, onRunIdChange: (nextRunId) => { setRunId(nextRunId) }, onResumeStateChange: (_nextResumeState, nextPendingInterrupts) => { setInterruptState((current) => ({ ...current, interrupts: nextPendingInterrupts, pendingInterrupts: nextPendingInterrupts, })) }, onInterruptStateChange: (nextInterruptState, context) => { setInterruptState(nextInterruptState) options.onInterruptStateChange?.(nextInterruptState, context) }, }) // Only recreate when clientId changes // Connection and other options are captured at creation time return instance }, [clientId]) setMessages(client().getMessages()) setHasOlderMessages(client().getHasOlderMessages()) syncResumeState() // Sync body / forwardedProps changes to the client. // Both populate the same wire payload; `forwardedProps` is preferred // and `body` is deprecated but still supported. createEffect(() => { // Conditional spread: `updateOptions` declares strict-optional // fields and rejects explicit `undefined` under EOPT. client().updateOptions({ ...(options.body !== undefined && { body: options.body }), ...(options.forwardedProps !== undefined && { forwardedProps: options.forwardedProps, }), context: options.context, ...(options.queue !== undefined && { queue: options.queue }), }) }) // Apply initial live mode immediately on hook creation. if (options.live) { client().subscribe() } else { client().unsubscribe() } createEffect(() => { if (options.live) { client().subscribe() } else { client().unsubscribe() } }) onMount(() => { // START TAILING HERE, not in the constructor. A client is idle until a view // attaches it, so a client that gets built and thrown away never opens a // connection — an unreachable stream would hold one of the browser's ~6 // connections per origin until the page reloaded. client().attach() client().mountDevtools() // Delivery-durability resume is transparent: the resumable SSE connection // adapter reattaches via the browser's native Last-Event-ID on reconnect. // We only seed interrupt (state) resume from the client here. syncResumeState() }) // Cleanup on unmount: stop any in-flight requests. onCleanup(() => { // Release the connection first: `detach` is the counterpart of the `attach` // above, and it keeps the transcript and resume pointer so a later mount can // pick the run back up from the durable log. client().detach() if (options.live) { client().unsubscribe() } else { client().stop() } client().dispose() }) // Callback options are read through `options.xxx` at call time, so reactive // or mutated options propagate without recreating the client. const sendMessage = async ( content: string | MultimodalContent, sendOptions?: SendMessageOptions, ) => { try { await client().sendMessage(content, undefined, sendOptions) } finally { syncResumeState() } } const append = async (message: ModelMessage | UIMessage) => { try { await client().append(message) } finally { syncResumeState() } } const reload = async () => { try { await client().reload() } finally { syncResumeState() } } const loadOlderMessages = async () => { await client().loadOlderMessages() setHasOlderMessages(client().getHasOlderMessages()) } const stop = () => { client().stop() } const clear = () => { client().clear() syncResumeState() } const setMessagesManually = (newMessages: Array>) => { client().setMessagesManually(newMessages) } const addToolResult = async (result: { toolCallId: string tool: string output: any state?: 'output-available' | 'output-error' errorText?: string }) => { await client().addToolResult(result) } const addToolApprovalResponse = async (response: { id: string approved: boolean }) => { await client().addToolApprovalResponse(response) syncResumeState() } const cancelQueued = (id: string) => client().cancelQueued(id) const resumeInterrupts = async ( resumeItems: Array, state?: ChatResumeState, ) => { const result = await client().resumeInterrupts(resumeItems, state) syncResumeState() return result } const resolveInterrupts = ( resolution: | boolean | (( interrupt: ResolvableChatInterrupt, ) => undefined), ) => { if (typeof resolution === 'boolean') { client().resolveInterrupts(resolution) } else { client().resolveInterrupts(resolution) } } const cancelInterrupts = () => { client().cancelInterrupts() } const retryInterrupts = () => { client().retryInterrupts() } const resumeInterruptsUnsafe = ( resumeItems: Array, state?: ChatResumeState, ) => client().resumeInterruptsUnsafe(resumeItems, state) const interrupts = () => interruptState().interrupts const pendingInterrupts = () => interruptState().pendingInterrupts const interruptErrors = () => interruptState().interruptErrors const resuming = () => interruptState().resuming // The "active" structured-output part is on the assistant message after // the latest user message. When no user message exists yet, return null // rather than scanning history — otherwise a stale `final` from // `initialMessages` would leak into the value on first render. const activeStructuredPart = createMemo(() => { const list = messages() let lastUserIndex = -1 for (let i = list.length - 1; i >= 0; i--) { if (list[i]?.role === 'user') { lastUserIndex = i break } } if (lastUserIndex === -1) return null for (let i = list.length - 1; i > lastUserIndex; i--) { const m = list[i] if (m?.role !== 'assistant') continue const part = m.parts.find( (p): p is StructuredOutputPart => p.type === 'structured-output', ) if (part) return part } return null }) const partial = createMemo(() => { const part = activeStructuredPart() if (!part) return {} as Partial const v = part.partial ?? part.data return (v ?? {}) as Partial }) const final = createMemo(() => { const part = activeStructuredPart() if (!part || part.status !== 'complete') return null return part.data as Final }) // partial / final are runtime-tracked unconditionally; the conditional // return type hides them when no `outputSchema` is supplied. // oxlint-disable-next-line eslint-js/no-restricted-syntax -- primitive return shape diverges from generic UseChatReturn; TS can't structurally narrow the conditional partial/final fields return { messages, queue, sendMessage, append, reload, stop, isLoading, hasOlderMessages, loadOlderMessages, error, status, isSubscribed, connectionStatus, sessionGenerating, setMessages: setMessagesManually, clear, addToolResult, addToolApprovalResponse, cancelQueued, runId, interrupts, pendingInterrupts, interruptErrors, resuming, resolveInterrupts, cancelInterrupts, retryInterrupts, resumeInterruptsUnsafe, resumeInterrupts, partial, final, } as unknown as UseChatReturn }