@lota-sdk/ui 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lota-sdk/ui",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -18,14 +18,14 @@
18
18
  "lint": "bunx oxlint --fix -c ../oxlint.config.ts src",
19
19
  "format": "bunx oxfmt src",
20
20
  "typecheck": "bunx tsgo --noEmit",
21
- "test:unit": "bun test ../tests/unit/ui"
21
+ "test:unit": "bun run scripts/test-unit.ts"
22
22
  },
23
23
  "publishConfig": {
24
24
  "access": "public",
25
25
  "registry": "https://registry.npmjs.org/"
26
26
  },
27
27
  "dependencies": {
28
- "@lota-sdk/shared": "0.3.0",
28
+ "@lota-sdk/shared": "0.3.2",
29
29
  "ai": "^6.0.145"
30
30
  },
31
31
  "peerDependencies": {
@@ -14,7 +14,15 @@ type UploadedAttachmentPayload = {
14
14
  url: string
15
15
  }
16
16
 
17
+ function hasLocalFile(value: FileUIPart): value is FileUIPart & { file: File } {
18
+ return 'file' in value && value.file instanceof File
19
+ }
20
+
17
21
  async function toUploadFile(filePart: FileUIPart): Promise<File> {
22
+ if (hasLocalFile(filePart)) {
23
+ return filePart.file
24
+ }
25
+
18
26
  if (!filePart.url) {
19
27
  throw new Error('Attachment payload is missing file data.')
20
28
  }
package/src/chat/index.ts CHANGED
@@ -1,4 +1,3 @@
1
1
  export * from './attachments'
2
2
  export * from './message-parts'
3
3
  export * from './messages'
4
- export * from './transport'
@@ -1,8 +1,8 @@
1
- import type { FileUIPart, UIMessage } from 'ai'
1
+ import type { UIMessage } from 'ai'
2
2
 
3
- export type ComposerMessage<TFile extends FileUIPart = FileUIPart> = { text: string; files?: TFile[] }
3
+ export type ComposerMessage<TFile = File> = { text: string; files?: TFile[] }
4
4
 
5
- export function validateComposerMessage<TFile extends FileUIPart = FileUIPart>(
5
+ export function validateComposerMessage<TFile = File>(
6
6
  message: ComposerMessage<TFile>,
7
7
  ): { textContent: string; files: TFile[] } | null {
8
8
  const textContent = message.text.trim()
@@ -1,7 +1,6 @@
1
1
  export * from './active-thread-helpers'
2
2
  export * from './session-common'
3
3
  export * from './use-active-thread-session'
4
- export * from './use-lota-chat'
5
4
  export * from './use-session-messages'
6
5
  export * from './use-thread-chat'
7
6
  export * from './use-thread-management'
@@ -1,5 +1,5 @@
1
1
  import { getMessageCreatedAt } from '@lota-sdk/shared'
2
- import type { FileUIPart, UIMessage } from 'ai'
2
+ import type { UIMessage } from 'ai'
3
3
  import { useCallback, useEffect, useMemo, useRef } from 'react'
4
4
 
5
5
  import { mergeChatMessages } from '../chat/messages'
@@ -21,7 +21,7 @@ interface ActiveThreadLike {
21
21
  export interface UseActiveThreadSessionOptions<
22
22
  TMessage extends UIMessage & { metadata?: { createdAt?: unknown } },
23
23
  TThread extends ActiveThreadLike,
24
- TFile extends FileUIPart = FileUIPart,
24
+ TFile = File,
25
25
  TTrackerState = never,
26
26
  > {
27
27
  threadId: string | null
@@ -49,7 +49,7 @@ export interface UseActiveThreadSessionOptions<
49
49
  isThreadBusy?: boolean
50
50
  }
51
51
 
52
- export interface UseActiveThreadSessionReturn<TMessage extends UIMessage, TFile extends FileUIPart = FileUIPart> {
52
+ export interface UseActiveThreadSessionReturn<TMessage extends UIMessage, TFile = File> {
53
53
  messages: TMessage[]
54
54
  status: ChatStatus
55
55
  error: Error | undefined
@@ -67,7 +67,7 @@ export interface UseActiveThreadSessionReturn<TMessage extends UIMessage, TFile
67
67
  export function useActiveThreadSession<
68
68
  TMessage extends UIMessage & { metadata?: { createdAt?: unknown } },
69
69
  TThread extends ActiveThreadLike,
70
- TFile extends FileUIPart = FileUIPart,
70
+ TFile = File,
71
71
  TTrackerState = never,
72
72
  >({
73
73
  threadId,
@@ -0,0 +1,249 @@
1
+ import type { UseChatHelpers } from '@ai-sdk/react'
2
+ import { AbstractChat } from 'ai'
3
+ import type { ChatInit, ChatState, ChatStatus, UIMessage } from 'ai'
4
+ import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react'
5
+
6
+ function throttleCallback(callback: () => void, waitMs?: number): () => void {
7
+ if (waitMs == null) {
8
+ return callback
9
+ }
10
+
11
+ let timeoutId: ReturnType<typeof setTimeout> | null = null
12
+ let hasPendingCall = false
13
+
14
+ return () => {
15
+ if (timeoutId !== null) {
16
+ hasPendingCall = true
17
+ return
18
+ }
19
+
20
+ callback()
21
+ timeoutId = setTimeout(() => {
22
+ timeoutId = null
23
+
24
+ if (!hasPendingCall) {
25
+ return
26
+ }
27
+
28
+ hasPendingCall = false
29
+ callback()
30
+ }, waitMs)
31
+ }
32
+ }
33
+
34
+ class SnapshottingChatState<UI_MESSAGE extends UIMessage> implements ChatState<UI_MESSAGE> {
35
+ #messages: UI_MESSAGE[]
36
+ #status: ChatStatus = 'ready'
37
+ #error: Error | undefined = undefined
38
+
39
+ #messagesCallbacks = new Set<() => void>()
40
+ #statusCallbacks = new Set<() => void>()
41
+ #errorCallbacks = new Set<() => void>()
42
+
43
+ constructor(initialMessages: UI_MESSAGE[] = []) {
44
+ this.#messages = initialMessages
45
+ }
46
+
47
+ get status(): ChatStatus {
48
+ return this.#status
49
+ }
50
+
51
+ set status(newStatus: ChatStatus) {
52
+ this.#status = newStatus
53
+ this.#callStatusCallbacks()
54
+ }
55
+
56
+ get error(): Error | undefined {
57
+ return this.#error
58
+ }
59
+
60
+ set error(newError: Error | undefined) {
61
+ this.#error = newError
62
+ this.#callErrorCallbacks()
63
+ }
64
+
65
+ get messages(): UI_MESSAGE[] {
66
+ return this.#messages
67
+ }
68
+
69
+ set messages(newMessages: UI_MESSAGE[]) {
70
+ this.#messages = [...newMessages]
71
+ this.#callMessagesCallbacks()
72
+ }
73
+
74
+ pushMessage = (message: UI_MESSAGE) => {
75
+ const lastMessage = this.#messages.at(-1)
76
+
77
+ if (lastMessage?.role === 'assistant' && message.role === 'assistant' && lastMessage.id !== message.id) {
78
+ // The AI SDK reuses one mutable assistant message object across consecutive `start` events
79
+ // in a single stream. When a second assistant starts, clear inherited parts so the new
80
+ // message begins empty instead of briefly rendering the previous assistant's content.
81
+ message.parts = [] as UI_MESSAGE['parts']
82
+ }
83
+
84
+ // Snapshot on insert so later stream mutations cannot rewrite already-rendered messages.
85
+ this.#messages = this.#messages.concat(this.snapshot(message))
86
+ this.#callMessagesCallbacks()
87
+ }
88
+
89
+ popMessage = () => {
90
+ this.#messages = this.#messages.slice(0, -1)
91
+ this.#callMessagesCallbacks()
92
+ }
93
+
94
+ replaceMessage = (index: number, message: UI_MESSAGE) => {
95
+ this.#messages = [...this.#messages.slice(0, index), this.snapshot(message), ...this.#messages.slice(index + 1)]
96
+ this.#callMessagesCallbacks()
97
+ }
98
+
99
+ snapshot = <T>(value: T): T => structuredClone(value)
100
+
101
+ '~registerMessagesCallback' = (onChange: () => void, throttleWaitMs?: number): (() => void) => {
102
+ const callback = throttleCallback(onChange, throttleWaitMs)
103
+ this.#messagesCallbacks.add(callback)
104
+ return () => {
105
+ this.#messagesCallbacks.delete(callback)
106
+ }
107
+ }
108
+
109
+ '~registerStatusCallback' = (onChange: () => void): (() => void) => {
110
+ this.#statusCallbacks.add(onChange)
111
+ return () => {
112
+ this.#statusCallbacks.delete(onChange)
113
+ }
114
+ }
115
+
116
+ '~registerErrorCallback' = (onChange: () => void): (() => void) => {
117
+ this.#errorCallbacks.add(onChange)
118
+ return () => {
119
+ this.#errorCallbacks.delete(onChange)
120
+ }
121
+ }
122
+
123
+ #callMessagesCallbacks = () => {
124
+ this.#messagesCallbacks.forEach((callback) => callback())
125
+ }
126
+
127
+ #callStatusCallbacks = () => {
128
+ this.#statusCallbacks.forEach((callback) => callback())
129
+ }
130
+
131
+ #callErrorCallbacks = () => {
132
+ this.#errorCallbacks.forEach((callback) => callback())
133
+ }
134
+ }
135
+
136
+ class SnapshottingChat<UI_MESSAGE extends UIMessage> extends AbstractChat<UI_MESSAGE> {
137
+ #state: SnapshottingChatState<UI_MESSAGE>
138
+
139
+ constructor({ messages, ...init }: ChatInit<UI_MESSAGE>) {
140
+ const state = new SnapshottingChatState(messages)
141
+ super({ ...init, state })
142
+ this.#state = state
143
+ }
144
+
145
+ '~registerMessagesCallback' = (onChange: () => void, throttleWaitMs?: number): (() => void) =>
146
+ this.#state['~registerMessagesCallback'](onChange, throttleWaitMs)
147
+
148
+ '~registerStatusCallback' = (onChange: () => void): (() => void) => this.#state['~registerStatusCallback'](onChange)
149
+
150
+ '~registerErrorCallback' = (onChange: () => void): (() => void) => this.#state['~registerErrorCallback'](onChange)
151
+ }
152
+
153
+ type UseSnapshottingChatOptions<UI_MESSAGE extends UIMessage> = ChatInit<UI_MESSAGE> & {
154
+ experimental_throttle?: number
155
+ resume?: boolean
156
+ }
157
+
158
+ export function useSnapshottingChat<UI_MESSAGE extends UIMessage = UIMessage>({
159
+ experimental_throttle: throttleWaitMs,
160
+ resume = false,
161
+ ...options
162
+ }: UseSnapshottingChatOptions<UI_MESSAGE> = {}): UseChatHelpers<UI_MESSAGE> {
163
+ const callbacksRef = useRef({
164
+ onToolCall: options.onToolCall,
165
+ onData: options.onData,
166
+ onFinish: options.onFinish,
167
+ onError: options.onError,
168
+ sendAutomaticallyWhen: options.sendAutomaticallyWhen,
169
+ })
170
+
171
+ callbacksRef.current = {
172
+ onToolCall: options.onToolCall,
173
+ onData: options.onData,
174
+ onFinish: options.onFinish,
175
+ onError: options.onError,
176
+ sendAutomaticallyWhen: options.sendAutomaticallyWhen,
177
+ }
178
+
179
+ const optionsWithCallbacks: ChatInit<UI_MESSAGE> = {
180
+ ...options,
181
+ onToolCall: (arg) => callbacksRef.current.onToolCall?.(arg),
182
+ onData: (arg) => callbacksRef.current.onData?.(arg),
183
+ onFinish: (arg) => callbacksRef.current.onFinish?.(arg),
184
+ onError: (arg) => callbacksRef.current.onError?.(arg),
185
+ sendAutomaticallyWhen: (arg) => callbacksRef.current.sendAutomaticallyWhen?.(arg) ?? false,
186
+ }
187
+
188
+ const chatRef = useRef<SnapshottingChat<UI_MESSAGE>>(new SnapshottingChat(optionsWithCallbacks))
189
+
190
+ if (chatRef.current.id !== (options.id ?? chatRef.current.id)) {
191
+ chatRef.current = new SnapshottingChat(optionsWithCallbacks)
192
+ }
193
+
194
+ const subscribeToMessages = useCallback(
195
+ (update: () => void) => chatRef.current['~registerMessagesCallback'](update, throttleWaitMs),
196
+ [throttleWaitMs, chatRef.current.id],
197
+ )
198
+
199
+ const messages = useSyncExternalStore(
200
+ subscribeToMessages,
201
+ () => chatRef.current.messages,
202
+ () => chatRef.current.messages,
203
+ )
204
+
205
+ const status = useSyncExternalStore(
206
+ chatRef.current['~registerStatusCallback'],
207
+ () => chatRef.current.status,
208
+ () => chatRef.current.status,
209
+ )
210
+
211
+ const error = useSyncExternalStore(
212
+ chatRef.current['~registerErrorCallback'],
213
+ () => chatRef.current.error,
214
+ () => chatRef.current.error,
215
+ )
216
+
217
+ const setMessages = useCallback(
218
+ (messagesParam: UI_MESSAGE[] | ((messages: UI_MESSAGE[]) => UI_MESSAGE[])) => {
219
+ if (typeof messagesParam === 'function') {
220
+ messagesParam = messagesParam(chatRef.current.messages)
221
+ }
222
+
223
+ chatRef.current.messages = messagesParam
224
+ },
225
+ [],
226
+ )
227
+
228
+ useEffect(() => {
229
+ if (resume) {
230
+ chatRef.current.resumeStream()
231
+ }
232
+ }, [resume])
233
+
234
+ return {
235
+ id: chatRef.current.id,
236
+ messages,
237
+ setMessages,
238
+ sendMessage: chatRef.current.sendMessage,
239
+ regenerate: chatRef.current.regenerate,
240
+ clearError: chatRef.current.clearError,
241
+ stop: chatRef.current.stop,
242
+ error,
243
+ resumeStream: chatRef.current.resumeStream,
244
+ status,
245
+ addToolResult: chatRef.current.addToolOutput,
246
+ addToolOutput: chatRef.current.addToolOutput,
247
+ addToolApprovalResponse: chatRef.current.addToolApprovalResponse,
248
+ }
249
+ }
@@ -1,9 +1,10 @@
1
- import { useChat } from '@ai-sdk/react'
2
1
  import type { UseChatHelpers } from '@ai-sdk/react'
3
2
  import { lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai'
4
3
  import type { ChatInit, UIMessage } from 'ai'
5
4
  import { useCallback, useEffect, useRef } from 'react'
6
5
 
6
+ import { useSnapshottingChat } from './use-snapshotting-chat'
7
+
7
8
  type ChatTransport<UI_MESSAGE extends UIMessage> = NonNullable<ChatInit<UI_MESSAGE>['transport']>
8
9
 
9
10
  export interface UseThreadChatOptions<UI_MESSAGE extends UIMessage> {
@@ -58,6 +59,9 @@ export function useThreadChat<UI_MESSAGE extends UIMessage>({
58
59
  const onDataRef = useRef(onData)
59
60
  const onMessagesSettledRef = useRef(onMessagesSettled)
60
61
  const setThreadRunningStateRef = useRef(setThreadRunningState)
62
+ const attemptedResumeForChatRef = useRef<string | null>(null)
63
+ const skipNextResumeForChatRef = useRef<string | null>(null)
64
+ const locallyStartedChatRef = useRef<string | null>(null)
61
65
 
62
66
  useEffect(() => {
63
67
  onDataRef.current = onData
@@ -78,13 +82,13 @@ export function useThreadChat<UI_MESSAGE extends UIMessage>({
78
82
  status,
79
83
  error,
80
84
  clearError,
81
- sendMessage,
82
- regenerate,
85
+ sendMessage: sendChatMessage,
86
+ regenerate: regenerateChat,
83
87
  stop: stopChatStream,
84
88
  setMessages,
85
89
  addToolApprovalResponse,
86
90
  resumeStream,
87
- } = useChat<UI_MESSAGE>({
91
+ } = useSnapshottingChat<UI_MESSAGE>({
88
92
  id: chatId,
89
93
  transport,
90
94
  messages: initialMessages,
@@ -98,10 +102,17 @@ export function useThreadChat<UI_MESSAGE extends UIMessage>({
98
102
  experimental_throttle: experimentalThrottle,
99
103
  sendAutomaticallyWhen,
100
104
  onFinish: () => {
105
+ if (locallyStartedChatRef.current === chatId) {
106
+ skipNextResumeForChatRef.current = chatId
107
+ locallyStartedChatRef.current = null
108
+ }
101
109
  setThreadRunningStateRef.current?.(false)
102
110
  onMessagesSettledRef.current?.()
103
111
  },
104
112
  onError: (error) => {
113
+ if (locallyStartedChatRef.current === chatId) {
114
+ locallyStartedChatRef.current = null
115
+ }
105
116
  if (!(error instanceof Error && error.name === 'AbortError')) {
106
117
  notifyError?.('Chat stream failed.', toError(error))
107
118
  }
@@ -113,13 +124,54 @@ export function useThreadChat<UI_MESSAGE extends UIMessage>({
113
124
  // Manual resume with ref guard — prevents React StrictMode double-fire.
114
125
  // We do NOT pass `resume` to useChat because the AI SDK's built-in effect
115
126
  // has no StrictMode guard, causing two concurrent GET streams.
116
- const resumedForChatRef = useRef<string | null>(null)
117
127
  useEffect(() => {
118
- if (!resume || status !== 'ready' || resumedForChatRef.current === chatId) return
119
- resumedForChatRef.current = chatId
128
+ if (!resume || status !== 'ready') return
129
+ if (skipNextResumeForChatRef.current === chatId) {
130
+ skipNextResumeForChatRef.current = null
131
+ return
132
+ }
133
+ if (attemptedResumeForChatRef.current === chatId) return
134
+ attemptedResumeForChatRef.current = chatId
120
135
  void resumeStream()
121
136
  }, [resume, status, chatId, resumeStream])
122
137
 
138
+ useEffect(() => {
139
+ if (status === 'ready' && resume) return
140
+ if (attemptedResumeForChatRef.current === chatId) {
141
+ attemptedResumeForChatRef.current = null
142
+ }
143
+ }, [chatId, resume, status])
144
+
145
+ const sendMessage = useCallback<UseChatHelpers<UI_MESSAGE>['sendMessage']>(
146
+ async (message, options) => {
147
+ locallyStartedChatRef.current = chatId
148
+ try {
149
+ return await sendChatMessage(message, options)
150
+ } catch (error) {
151
+ if (locallyStartedChatRef.current === chatId) {
152
+ locallyStartedChatRef.current = null
153
+ }
154
+ throw error
155
+ }
156
+ },
157
+ [chatId, sendChatMessage],
158
+ )
159
+
160
+ const regenerate = useCallback<UseChatHelpers<UI_MESSAGE>['regenerate']>(
161
+ async (options) => {
162
+ locallyStartedChatRef.current = chatId
163
+ try {
164
+ return await regenerateChat(options)
165
+ } catch (error) {
166
+ if (locallyStartedChatRef.current === chatId) {
167
+ locallyStartedChatRef.current = null
168
+ }
169
+ throw error
170
+ }
171
+ },
172
+ [chatId, regenerateChat],
173
+ )
174
+
123
175
  const stop = useCallback(async () => {
124
176
  const stopRequest = threadId && stopRemoteRun ? stopRemoteRun(threadId) : Promise.resolve(null)
125
177
  const [stopResponse] = await Promise.allSettled([stopRequest, stopChatStream()])
@@ -5,7 +5,7 @@ const PLACEHOLDER_THREAD_PREFIX = '__placeholder_'
5
5
  export interface ThreadManagementItem<TAgentId extends string = string> {
6
6
  threadId: string
7
7
  title: string
8
- status: 'regular' | 'archived'
8
+ status: 'active' | 'archived'
9
9
  type: string
10
10
  isMutable: boolean
11
11
  agentId?: TAgentId | null
@@ -62,7 +62,7 @@ export function findReusableRegularGroupThreadId<TThread extends ThreadManagemen
62
62
  defaultThreadTitle: string,
63
63
  ): string | null {
64
64
  const draft = threads.find(
65
- (thread) => thread.type === 'group' && thread.status === 'regular' && thread.title.trim() === defaultThreadTitle,
65
+ (thread) => thread.type === 'group' && thread.status === 'active' && thread.title.trim() === defaultThreadTitle,
66
66
  )
67
67
 
68
68
  return draft?.threadId ?? null
@@ -245,6 +245,7 @@ export function useThreadManagement<TThread extends ThreadManagementItem<TAgentI
245
245
  return
246
246
  }
247
247
 
248
+ if (!enabled) return
248
249
  if (isCreatingThreadRef.current) return
249
250
  if (isCreatingThread) return
250
251
  isCreatingThreadRef.current = true
@@ -257,6 +258,7 @@ export function useThreadManagement<TThread extends ThreadManagementItem<TAgentI
257
258
  isCreatingThreadRef.current = false
258
259
  })
259
260
  }, [
261
+ enabled,
260
262
  currentThreadId,
261
263
  handleCreateNewThread,
262
264
  isCreatingThread,
@@ -1,8 +0,0 @@
1
- import { DefaultChatTransport } from 'ai'
2
- import type { HttpChatTransportInitOptions, UIMessage } from 'ai'
3
-
4
- export type ChatTransportOptions<UI_MESSAGE extends UIMessage> = HttpChatTransportInitOptions<UI_MESSAGE>
5
-
6
- export function createChatTransport<UI_MESSAGE extends UIMessage>(options: ChatTransportOptions<UI_MESSAGE>) {
7
- return new DefaultChatTransport<UI_MESSAGE>(options)
8
- }
@@ -1,13 +0,0 @@
1
- import type { UIMessage } from 'ai'
2
-
3
- import type { UseThreadChatOptions, UseThreadChatReturn } from './use-thread-chat'
4
- import { useThreadChat } from './use-thread-chat'
5
-
6
- export type UseLotaChatOptions<UI_MESSAGE extends UIMessage> = UseThreadChatOptions<UI_MESSAGE>
7
- export type UseLotaChatReturn<UI_MESSAGE extends UIMessage> = UseThreadChatReturn<UI_MESSAGE>
8
-
9
- export function useLotaChat<UI_MESSAGE extends UIMessage>(
10
- options: UseLotaChatOptions<UI_MESSAGE>,
11
- ): UseLotaChatReturn<UI_MESSAGE> {
12
- return useThreadChat(options)
13
- }