@lota-sdk/ui 0.2.3 → 0.3.1

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.2.3",
3
+ "version": "0.3.1",
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.2.3",
28
+ "@lota-sdk/shared": "0.3.1",
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,
@@ -58,6 +58,9 @@ export function useThreadChat<UI_MESSAGE extends UIMessage>({
58
58
  const onDataRef = useRef(onData)
59
59
  const onMessagesSettledRef = useRef(onMessagesSettled)
60
60
  const setThreadRunningStateRef = useRef(setThreadRunningState)
61
+ const attemptedResumeForChatRef = useRef<string | null>(null)
62
+ const skipNextResumeForChatRef = useRef<string | null>(null)
63
+ const locallyStartedChatRef = useRef<string | null>(null)
61
64
 
62
65
  useEffect(() => {
63
66
  onDataRef.current = onData
@@ -78,8 +81,8 @@ export function useThreadChat<UI_MESSAGE extends UIMessage>({
78
81
  status,
79
82
  error,
80
83
  clearError,
81
- sendMessage,
82
- regenerate,
84
+ sendMessage: sendChatMessage,
85
+ regenerate: regenerateChat,
83
86
  stop: stopChatStream,
84
87
  setMessages,
85
88
  addToolApprovalResponse,
@@ -98,10 +101,17 @@ export function useThreadChat<UI_MESSAGE extends UIMessage>({
98
101
  experimental_throttle: experimentalThrottle,
99
102
  sendAutomaticallyWhen,
100
103
  onFinish: () => {
104
+ if (locallyStartedChatRef.current === chatId) {
105
+ skipNextResumeForChatRef.current = chatId
106
+ locallyStartedChatRef.current = null
107
+ }
101
108
  setThreadRunningStateRef.current?.(false)
102
109
  onMessagesSettledRef.current?.()
103
110
  },
104
111
  onError: (error) => {
112
+ if (locallyStartedChatRef.current === chatId) {
113
+ locallyStartedChatRef.current = null
114
+ }
105
115
  if (!(error instanceof Error && error.name === 'AbortError')) {
106
116
  notifyError?.('Chat stream failed.', toError(error))
107
117
  }
@@ -113,13 +123,54 @@ export function useThreadChat<UI_MESSAGE extends UIMessage>({
113
123
  // Manual resume with ref guard — prevents React StrictMode double-fire.
114
124
  // We do NOT pass `resume` to useChat because the AI SDK's built-in effect
115
125
  // has no StrictMode guard, causing two concurrent GET streams.
116
- const resumedForChatRef = useRef<string | null>(null)
117
126
  useEffect(() => {
118
- if (!resume || status !== 'ready' || resumedForChatRef.current === chatId) return
119
- resumedForChatRef.current = chatId
127
+ if (!resume || status !== 'ready') return
128
+ if (skipNextResumeForChatRef.current === chatId) {
129
+ skipNextResumeForChatRef.current = null
130
+ return
131
+ }
132
+ if (attemptedResumeForChatRef.current === chatId) return
133
+ attemptedResumeForChatRef.current = chatId
120
134
  void resumeStream()
121
135
  }, [resume, status, chatId, resumeStream])
122
136
 
137
+ useEffect(() => {
138
+ if (status === 'ready' && resume) return
139
+ if (attemptedResumeForChatRef.current === chatId) {
140
+ attemptedResumeForChatRef.current = null
141
+ }
142
+ }, [chatId, resume, status])
143
+
144
+ const sendMessage = useCallback<UseChatHelpers<UI_MESSAGE>['sendMessage']>(
145
+ async (message, options) => {
146
+ locallyStartedChatRef.current = chatId
147
+ try {
148
+ return await sendChatMessage(message, options)
149
+ } catch (error) {
150
+ if (locallyStartedChatRef.current === chatId) {
151
+ locallyStartedChatRef.current = null
152
+ }
153
+ throw error
154
+ }
155
+ },
156
+ [chatId, sendChatMessage],
157
+ )
158
+
159
+ const regenerate = useCallback<UseChatHelpers<UI_MESSAGE>['regenerate']>(
160
+ async (options) => {
161
+ locallyStartedChatRef.current = chatId
162
+ try {
163
+ return await regenerateChat(options)
164
+ } catch (error) {
165
+ if (locallyStartedChatRef.current === chatId) {
166
+ locallyStartedChatRef.current = null
167
+ }
168
+ throw error
169
+ }
170
+ },
171
+ [chatId, regenerateChat],
172
+ )
173
+
123
174
  const stop = useCallback(async () => {
124
175
  const stopRequest = threadId && stopRemoteRun ? stopRemoteRun(threadId) : Promise.resolve(null)
125
176
  const [stopResponse] = await Promise.allSettled([stopRequest, stopChatStream()])
@@ -5,9 +5,8 @@ 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'
9
- mode: 'direct' | 'group'
10
- core: boolean
8
+ status: 'active' | 'archived'
9
+ type: string
11
10
  isMutable: boolean
12
11
  agentId?: TAgentId | null
13
12
  }
@@ -63,11 +62,7 @@ export function findReusableRegularGroupThreadId<TThread extends ThreadManagemen
63
62
  defaultThreadTitle: string,
64
63
  ): string | null {
65
64
  const draft = threads.find(
66
- (thread) =>
67
- thread.mode === 'group' &&
68
- !thread.core &&
69
- thread.status === 'regular' &&
70
- thread.title.trim() === defaultThreadTitle,
65
+ (thread) => thread.type === 'group' && thread.status === 'active' && thread.title.trim() === defaultThreadTitle,
71
66
  )
72
67
 
73
68
  return draft?.threadId ?? null
@@ -151,7 +146,7 @@ export function useThreadManagement<TThread extends ThreadManagementItem<TAgentI
151
146
  ? (() => {
152
147
  const agentId = threadId.slice(placeholderThreadPrefix.length) as TAgentId
153
148
  const directThread = allThreadsRef.current.find(
154
- (thread) => thread.mode === 'direct' && thread.agentId === agentId,
149
+ (thread) => thread.type === 'default' && thread.agentId === agentId,
155
150
  )
156
151
  return directThread?.threadId
157
152
  })()
@@ -160,7 +155,7 @@ export function useThreadManagement<TThread extends ThreadManagementItem<TAgentI
160
155
  if (!resolvedThreadId) return
161
156
 
162
157
  const target = allThreadsRef.current.find((thread) => thread.threadId === resolvedThreadId)
163
- const nextAgentId = target?.mode === 'direct' && target.agentId ? target.agentId : null
158
+ const nextAgentId = target?.type === 'default' && target.agentId ? target.agentId : null
164
159
  setCurrentThreadId(resolvedThreadId)
165
160
  setSelectedAgentId(nextAgentId)
166
161
  },
@@ -243,13 +238,14 @@ export function useThreadManagement<TThread extends ThreadManagementItem<TAgentI
243
238
  const preferredThread =
244
239
  primaryDirectAgentId === undefined
245
240
  ? threads[0]
246
- : (threads.find((thread) => thread.mode === 'direct' && thread.agentId === primaryDirectAgentId) ??
241
+ : (threads.find((thread) => thread.type === 'default' && thread.agentId === primaryDirectAgentId) ??
247
242
  threads[0])
248
243
  setCurrentThreadId(preferredThread.threadId)
249
- setSelectedAgentId(preferredThread.mode === 'direct' && preferredThread.agentId ? preferredThread.agentId : null)
244
+ setSelectedAgentId(preferredThread.type === 'default' && preferredThread.agentId ? preferredThread.agentId : null)
250
245
  return
251
246
  }
252
247
 
248
+ if (!enabled) return
253
249
  if (isCreatingThreadRef.current) return
254
250
  if (isCreatingThread) return
255
251
  isCreatingThreadRef.current = true
@@ -262,6 +258,7 @@ export function useThreadManagement<TThread extends ThreadManagementItem<TAgentI
262
258
  isCreatingThreadRef.current = false
263
259
  })
264
260
  }, [
261
+ enabled,
265
262
  currentThreadId,
266
263
  handleCreateNewThread,
267
264
  isCreatingThread,
@@ -276,7 +273,7 @@ export function useThreadManagement<TThread extends ThreadManagementItem<TAgentI
276
273
  if (!currentThreadId?.startsWith(placeholderThreadPrefix)) return
277
274
 
278
275
  const agentId = currentThreadId.slice(placeholderThreadPrefix.length) as TAgentId
279
- const directThread = allThreads.find((thread) => thread.mode === 'direct' && thread.agentId === agentId)
276
+ const directThread = allThreads.find((thread) => thread.type === 'default' && thread.agentId === agentId)
280
277
  if (!directThread) return
281
278
 
282
279
  setCurrentThreadId(directThread.threadId)
@@ -286,7 +283,7 @@ export function useThreadManagement<TThread extends ThreadManagementItem<TAgentI
286
283
  useEffect(() => {
287
284
  if (currentThreadId !== null || selectedAgentId === null) return
288
285
 
289
- const directThread = allThreads.find((thread) => thread.mode === 'direct' && thread.agentId === selectedAgentId)
286
+ const directThread = allThreads.find((thread) => thread.type === 'default' && thread.agentId === selectedAgentId)
290
287
  if (!directThread) return
291
288
 
292
289
  setCurrentThreadId(directThread.threadId)
@@ -303,7 +300,7 @@ export function useThreadManagement<TThread extends ThreadManagementItem<TAgentI
303
300
  const activeThread = allThreads.find((thread) => thread.threadId === currentThreadId)
304
301
  if (!activeThread) return
305
302
 
306
- const expectedAgentId = activeThread.mode === 'direct' && activeThread.agentId ? activeThread.agentId : null
303
+ const expectedAgentId = activeThread.type === 'default' && activeThread.agentId ? activeThread.agentId : null
307
304
  if (selectedAgentId !== expectedAgentId) {
308
305
  setSelectedAgentId(expectedAgentId)
309
306
  }
@@ -1,4 +1,4 @@
1
- import type { SerializablePlanNode } from '@lota-sdk/shared'
1
+ import type { ExecutionPlanNodeSummary } from '@lota-sdk/shared'
2
2
  import { getLatestExecutionPlanResult } from '@lota-sdk/shared'
3
3
 
4
4
  export { getLatestExecutionPlanResult }
@@ -33,14 +33,13 @@ export function getExecutionPlanStatusLabel(status: string | null | undefined):
33
33
  }
34
34
 
35
35
  export function getExecutionPlanOwnerLabel(
36
- node: SerializablePlanNode,
36
+ node: ExecutionPlanNodeSummary,
37
37
  displayNameByOwnerRef: Record<string, string> = {},
38
38
  ): string {
39
- if (node.owner.executorType === 'agent' && node.owner.ref in displayNameByOwnerRef) {
40
- return displayNameByOwnerRef[node.owner.ref]
39
+ if (node.ownerRef in displayNameByOwnerRef) {
40
+ return displayNameByOwnerRef[node.ownerRef]
41
41
  }
42
- if (node.owner.executorType === 'user') return 'User'
43
- return node.owner.ref
42
+ return node.ownerRef
44
43
  }
45
44
 
46
45
  export function buildExecutionPlanToolViewModel(params: { input: unknown; isRunning: boolean; output: unknown }) {
@@ -54,12 +53,11 @@ export function buildExecutionPlanToolViewModel(params: { input: unknown; isRunn
54
53
  ? params.input.title.trim()
55
54
  : ''
56
55
  const title = plan && plan.title.trim() ? plan.title.trim() : inputTitle || 'Execution run'
57
- const completedCount = plan ? plan.progress.completed + plan.progress.partial : 0
56
+ const completedCount = plan ? plan.progress.completed : 0
58
57
 
59
58
  return {
60
59
  actionLabel: getExecutionPlanActionLabel(result?.action ?? 'none', params.isRunning),
61
60
  hasPlan: plan !== null,
62
- latestEvent: plan ? plan.recentEvents.at(-1)?.message.trim() : undefined,
63
61
  plan,
64
62
  progressSummary: plan ? `${completedCount}/${plan.progress.total} complete` : 'No active execution run',
65
63
  result,
@@ -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
- }