@lota-sdk/ui 0.1.5

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.
@@ -0,0 +1,122 @@
1
+ import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'
2
+ import type { InfiniteData } from '@tanstack/react-query'
3
+ import { useCallback, useMemo } from 'react'
4
+
5
+ import { normalizeChatMessages } from '../chat/messages'
6
+
7
+ export interface SessionMessagesPageResponse {
8
+ messages: unknown[]
9
+ hasMore: boolean
10
+ prevCursor: string | null
11
+ }
12
+
13
+ interface SessionMessagesPage<TMessage> {
14
+ messages: TMessage[]
15
+ hasMore: boolean
16
+ prevCursor: string | null
17
+ }
18
+
19
+ export interface UseSessionMessagesConfig<TMessage extends { id: string; metadata?: { createdAt?: unknown } }> {
20
+ sessionId: string | null
21
+ queryKey: readonly unknown[]
22
+ fetchLatestPage: (id: string) => Promise<SessionMessagesPageResponse>
23
+ fetchOlderPage: (id: string, cursor: string) => Promise<SessionMessagesPageResponse>
24
+ pollIntervalMs: number
25
+ pollEnabled: boolean
26
+ validateMessages?: (messages: unknown[]) => Promise<TMessage[]>
27
+ }
28
+
29
+ export interface UseSessionMessagesReturn<TMessage> {
30
+ messages: TMessage[]
31
+ isLoading: boolean
32
+ hasOlderMessages: boolean
33
+ isLoadingOlderMessages: boolean
34
+ loadOlderMessages: () => Promise<void>
35
+ refreshLatestPage: () => Promise<void>
36
+ }
37
+
38
+ function deduplicateAndSort<TMessage extends { id: string; metadata?: { createdAt?: unknown } }>(
39
+ messages: TMessage[],
40
+ ): TMessage[] {
41
+ return normalizeChatMessages(Array.from(new Map(messages.map((message) => [message.id, message])).values()))
42
+ }
43
+
44
+ async function normalizePage<TMessage extends { id: string; metadata?: { createdAt?: unknown } }>(params: {
45
+ response: SessionMessagesPageResponse
46
+ validateMessages?: (messages: unknown[]) => Promise<TMessage[]>
47
+ }): Promise<SessionMessagesPage<TMessage>> {
48
+ const validatedMessages = params.validateMessages
49
+ ? await params.validateMessages(params.response.messages)
50
+ : (params.response.messages as TMessage[])
51
+
52
+ return {
53
+ messages: normalizeChatMessages(validatedMessages),
54
+ hasMore: params.response.hasMore,
55
+ prevCursor: params.response.prevCursor,
56
+ }
57
+ }
58
+
59
+ export function useSessionMessages<TMessage extends { id: string; metadata?: { createdAt?: unknown } }>({
60
+ sessionId,
61
+ queryKey,
62
+ fetchLatestPage,
63
+ fetchOlderPage,
64
+ pollIntervalMs,
65
+ pollEnabled,
66
+ validateMessages,
67
+ }: UseSessionMessagesConfig<TMessage>): UseSessionMessagesReturn<TMessage> {
68
+ const queryClient = useQueryClient()
69
+ const infiniteQuery = useInfiniteQuery({
70
+ queryKey,
71
+ queryFn: async ({ pageParam }) => {
72
+ if (!sessionId) {
73
+ return { messages: [], hasMore: false, prevCursor: null } satisfies SessionMessagesPage<TMessage>
74
+ }
75
+
76
+ const response = pageParam ? await fetchOlderPage(sessionId, pageParam) : await fetchLatestPage(sessionId)
77
+ return await normalizePage({ response, validateMessages })
78
+ },
79
+ getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.prevCursor : undefined),
80
+ initialPageParam: undefined as string | undefined,
81
+ enabled: Boolean(sessionId),
82
+ refetchInterval: pollEnabled ? pollIntervalMs : false,
83
+ refetchOnWindowFocus: false,
84
+ })
85
+
86
+ const messages = useMemo(() => {
87
+ if (!infiniteQuery.data) return []
88
+ return deduplicateAndSort(infiniteQuery.data.pages.flatMap((page) => page.messages))
89
+ }, [infiniteQuery.data])
90
+
91
+ const loadOlderMessages = useCallback(async () => {
92
+ if (infiniteQuery.hasNextPage && !infiniteQuery.isFetchingNextPage) {
93
+ await infiniteQuery.fetchNextPage()
94
+ }
95
+ }, [infiniteQuery])
96
+
97
+ const refreshLatestPage = useCallback(async () => {
98
+ if (!sessionId) return
99
+
100
+ const latestPage = await normalizePage({ response: await fetchLatestPage(sessionId), validateMessages })
101
+
102
+ queryClient.setQueryData(queryKey, (cached: InfiniteData<SessionMessagesPage<TMessage>> | undefined) => {
103
+ if (!cached || cached.pages.length === 0) {
104
+ return { pages: [latestPage], pageParams: [undefined] }
105
+ }
106
+
107
+ const pages = [...cached.pages]
108
+ pages[0] = latestPage
109
+
110
+ return { ...cached, pages, pageParams: cached.pageParams.length > 0 ? cached.pageParams : [undefined] }
111
+ })
112
+ }, [fetchLatestPage, queryClient, queryKey, sessionId, validateMessages])
113
+
114
+ return {
115
+ messages,
116
+ isLoading: infiniteQuery.isLoading,
117
+ hasOlderMessages: infiniteQuery.hasNextPage,
118
+ isLoadingOlderMessages: infiniteQuery.isFetchingNextPage,
119
+ loadOlderMessages,
120
+ refreshLatestPage,
121
+ }
122
+ }
@@ -0,0 +1,109 @@
1
+ import { useChat } from '@ai-sdk/react'
2
+ import type { UseChatHelpers } from '@ai-sdk/react'
3
+ import { lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai'
4
+ import type { ChatInit, UIMessage } from 'ai'
5
+ import { useCallback, useEffect, useRef } from 'react'
6
+
7
+ type ChatTransport<UI_MESSAGE extends UIMessage> = NonNullable<ChatInit<UI_MESSAGE>['transport']>
8
+
9
+ export interface UseThreadChatOptions<UI_MESSAGE extends UIMessage> {
10
+ threadId: string | null
11
+ initialMessages: UI_MESSAGE[]
12
+ transport: ChatTransport<UI_MESSAGE>
13
+ messageMetadataSchema?: ChatInit<UI_MESSAGE>['messageMetadataSchema']
14
+ dataPartSchemas?: ChatInit<UI_MESSAGE>['dataPartSchemas']
15
+ onMessagesSettled?: () => void
16
+ setThreadRunningState?: (isRunning: boolean) => void
17
+ notifyError?: (message: string, error: Error) => void
18
+ stopRemoteRun?: (threadId: string) => Promise<unknown>
19
+ experimentalThrottle?: number
20
+ sendAutomaticallyWhen?: ChatInit<UI_MESSAGE>['sendAutomaticallyWhen']
21
+ resume?: boolean
22
+ idPrefix?: string
23
+ }
24
+
25
+ export interface UseThreadChatReturn<UI_MESSAGE extends UIMessage> {
26
+ messages: UI_MESSAGE[]
27
+ status: UseChatHelpers<UI_MESSAGE>['status']
28
+ error: Error | undefined
29
+ clearError: () => void
30
+ sendMessage: UseChatHelpers<UI_MESSAGE>['sendMessage']
31
+ regenerate: UseChatHelpers<UI_MESSAGE>['regenerate']
32
+ stop: () => Promise<void>
33
+ setMessages: UseChatHelpers<UI_MESSAGE>['setMessages']
34
+ addToolApprovalResponse: UseChatHelpers<UI_MESSAGE>['addToolApprovalResponse']
35
+ }
36
+
37
+ function toError(error: unknown): Error {
38
+ return error instanceof Error ? error : new Error(String(error))
39
+ }
40
+
41
+ export function useThreadChat<UI_MESSAGE extends UIMessage>({
42
+ threadId,
43
+ initialMessages,
44
+ transport,
45
+ messageMetadataSchema,
46
+ dataPartSchemas,
47
+ onMessagesSettled,
48
+ setThreadRunningState,
49
+ notifyError,
50
+ stopRemoteRun,
51
+ experimentalThrottle = 50,
52
+ sendAutomaticallyWhen = lastAssistantMessageIsCompleteWithApprovalResponses,
53
+ resume = false,
54
+ idPrefix = 'thread',
55
+ }: UseThreadChatOptions<UI_MESSAGE>): UseThreadChatReturn<UI_MESSAGE> {
56
+ const onMessagesSettledRef = useRef(onMessagesSettled)
57
+ const setThreadRunningStateRef = useRef(setThreadRunningState)
58
+
59
+ useEffect(() => {
60
+ onMessagesSettledRef.current = onMessagesSettled
61
+ }, [onMessagesSettled])
62
+
63
+ useEffect(() => {
64
+ setThreadRunningStateRef.current = setThreadRunningState
65
+ }, [setThreadRunningState])
66
+
67
+ const {
68
+ messages,
69
+ status,
70
+ error,
71
+ clearError,
72
+ sendMessage,
73
+ regenerate,
74
+ stop: stopChatStream,
75
+ setMessages,
76
+ addToolApprovalResponse,
77
+ } = useChat<UI_MESSAGE>({
78
+ id: threadId ? `${idPrefix}:${threadId}` : `${idPrefix}:none`,
79
+ transport,
80
+ messages: initialMessages,
81
+ messageMetadataSchema,
82
+ dataPartSchemas,
83
+ resume,
84
+ experimental_throttle: experimentalThrottle,
85
+ sendAutomaticallyWhen,
86
+ onFinish: () => {
87
+ setThreadRunningStateRef.current?.(false)
88
+ onMessagesSettledRef.current?.()
89
+ },
90
+ onError: (error) => {
91
+ if (!(error instanceof Error && error.name === 'AbortError')) {
92
+ notifyError?.('Chat stream failed.', toError(error))
93
+ }
94
+ setThreadRunningStateRef.current?.(false)
95
+ onMessagesSettledRef.current?.()
96
+ },
97
+ })
98
+
99
+ const stop = useCallback(async () => {
100
+ const stopRequest = threadId && stopRemoteRun ? stopRemoteRun(threadId) : Promise.resolve(null)
101
+ const [stopResponse] = await Promise.allSettled([stopRequest, stopChatStream()])
102
+ if (stopResponse.status === 'rejected') {
103
+ notifyError?.('Failed to stop chat.', toError(stopResponse.reason))
104
+ }
105
+ setThreadRunningStateRef.current?.(false)
106
+ }, [notifyError, stopChatStream, stopRemoteRun, threadId])
107
+
108
+ return { messages, status, error, clearError, sendMessage, regenerate, stop, setMessages, addToolApprovalResponse }
109
+ }
@@ -0,0 +1,328 @@
1
+ import { useCallback, useEffect, useMemo, useRef } from 'react'
2
+
3
+ const PLACEHOLDER_THREAD_PREFIX = '__placeholder_'
4
+
5
+ export interface ThreadManagementItem<TAgentId extends string = string> {
6
+ threadId: string
7
+ title: string
8
+ status: 'regular' | 'archived'
9
+ mode: 'direct' | 'group'
10
+ core: boolean
11
+ isMutable: boolean
12
+ agentId?: TAgentId | null
13
+ }
14
+
15
+ export interface UseThreadManagementOptions<TThread extends ThreadManagementItem<TAgentId>, TAgentId extends string> {
16
+ enabled?: boolean
17
+ isCreatingThread?: boolean
18
+ threads: TThread[]
19
+ coreThreads: TThread[]
20
+ archivedThreads: TThread[]
21
+ currentThreadId: string | null
22
+ selectedAgentId: TAgentId | null
23
+ isLoadingThreads: boolean
24
+ isRefetchingThreads: boolean
25
+ hasMoreThreads: boolean
26
+ isFetchingNextThreadsPage: boolean
27
+ fetchNextThreadsPageRaw: () => Promise<unknown> | void
28
+ createThread: () => Promise<{ id: string }>
29
+ renameThread: (params: { threadId: string; title: string }) => Promise<unknown>
30
+ archiveThread: (threadId: string) => Promise<unknown>
31
+ unarchiveThread: (threadId: string) => Promise<unknown>
32
+ deleteThread: (threadId: string) => Promise<unknown>
33
+ setCurrentThreadId: (threadId: string | null) => void
34
+ setSelectedAgentId: (agentId: TAgentId | null) => void
35
+ defaultThreadTitle: string
36
+ primaryDirectAgentId?: TAgentId
37
+ placeholderThreadPrefix?: string
38
+ findReusableThreadId?: (threads: TThread[]) => string | null
39
+ notifyBackgroundError?: (error: unknown, message: string) => void
40
+ }
41
+
42
+ export interface UseThreadManagementReturn<TThread extends ThreadManagementItem<TAgentId>, TAgentId extends string> {
43
+ threads: TThread[]
44
+ coreThreads: TThread[]
45
+ archivedThreads: TThread[]
46
+ currentThreadId: string | null
47
+ selectedAgentId: TAgentId | null
48
+ isLoadingThreads: boolean
49
+ isRefetchingThreads: boolean
50
+ hasMoreThreads: boolean
51
+ isFetchingNextThreadsPage: boolean
52
+ fetchNextThreadsPage: () => void
53
+ handleCreateNewThread: () => Promise<void>
54
+ handleSwitchThread: (threadId: string) => void
55
+ handleRenameThread: (threadId: string, newTitle: string) => Promise<void>
56
+ handleArchiveThread: (threadId: string) => Promise<void>
57
+ handleUnarchiveThread: (threadId: string) => Promise<void>
58
+ handleDeleteThread: (threadId: string) => Promise<void>
59
+ }
60
+
61
+ export function findReusableRegularGroupThreadId<TThread extends ThreadManagementItem>(
62
+ threads: TThread[],
63
+ defaultThreadTitle: string,
64
+ ): string | null {
65
+ const draft = threads.find(
66
+ (thread) =>
67
+ thread.mode === 'group' &&
68
+ !thread.core &&
69
+ thread.status === 'regular' &&
70
+ thread.title.trim() === defaultThreadTitle,
71
+ )
72
+
73
+ return draft?.threadId ?? null
74
+ }
75
+
76
+ export function useThreadManagement<TThread extends ThreadManagementItem<TAgentId>, TAgentId extends string>({
77
+ enabled = true,
78
+ isCreatingThread = false,
79
+ threads,
80
+ coreThreads,
81
+ archivedThreads,
82
+ currentThreadId,
83
+ selectedAgentId,
84
+ isLoadingThreads,
85
+ isRefetchingThreads,
86
+ hasMoreThreads,
87
+ isFetchingNextThreadsPage,
88
+ fetchNextThreadsPageRaw,
89
+ createThread,
90
+ renameThread,
91
+ archiveThread,
92
+ unarchiveThread,
93
+ deleteThread,
94
+ setCurrentThreadId,
95
+ setSelectedAgentId,
96
+ defaultThreadTitle,
97
+ primaryDirectAgentId,
98
+ placeholderThreadPrefix = PLACEHOLDER_THREAD_PREFIX,
99
+ findReusableThreadId,
100
+ notifyBackgroundError,
101
+ }: UseThreadManagementOptions<TThread, TAgentId>): UseThreadManagementReturn<TThread, TAgentId> {
102
+ const fetchNextThreadsPage = useCallback(() => {
103
+ if (!enabled) return
104
+ if (hasMoreThreads && !isFetchingNextThreadsPage) {
105
+ void fetchNextThreadsPageRaw()
106
+ }
107
+ }, [enabled, fetchNextThreadsPageRaw, hasMoreThreads, isFetchingNextThreadsPage])
108
+
109
+ const allThreads = useMemo(() => [...threads, ...archivedThreads], [threads, archivedThreads])
110
+ const isCreatingThreadRef = useRef(false)
111
+ const allThreadsRef = useRef<TThread[]>(allThreads)
112
+ const threadsRef = useRef<TThread[]>(threads)
113
+ const currentThreadIdRef = useRef<string | null>(currentThreadId)
114
+ const createThreadRef = useRef(createThread)
115
+ const renameThreadRef = useRef(renameThread)
116
+ const archiveThreadRef = useRef(archiveThread)
117
+ const unarchiveThreadRef = useRef(unarchiveThread)
118
+ const deleteThreadRef = useRef(deleteThread)
119
+
120
+ useEffect(() => {
121
+ allThreadsRef.current = allThreads
122
+ }, [allThreads])
123
+
124
+ useEffect(() => {
125
+ threadsRef.current = threads
126
+ }, [threads])
127
+
128
+ useEffect(() => {
129
+ currentThreadIdRef.current = currentThreadId
130
+ }, [currentThreadId])
131
+
132
+ useEffect(() => {
133
+ createThreadRef.current = createThread
134
+ renameThreadRef.current = renameThread
135
+ archiveThreadRef.current = archiveThread
136
+ unarchiveThreadRef.current = unarchiveThread
137
+ deleteThreadRef.current = deleteThread
138
+ }, [archiveThread, createThread, deleteThread, renameThread, unarchiveThread])
139
+
140
+ const resolveReusableThreadId = useCallback(
141
+ (candidateThreads: TThread[]) =>
142
+ findReusableThreadId
143
+ ? findReusableThreadId(candidateThreads)
144
+ : findReusableRegularGroupThreadId(candidateThreads, defaultThreadTitle),
145
+ [defaultThreadTitle, findReusableThreadId],
146
+ )
147
+
148
+ const handleSwitchThread = useCallback(
149
+ (threadId: string) => {
150
+ const resolvedThreadId = threadId.startsWith(placeholderThreadPrefix)
151
+ ? (() => {
152
+ const agentId = threadId.slice(placeholderThreadPrefix.length) as TAgentId
153
+ const directThread = allThreadsRef.current.find(
154
+ (thread) => thread.mode === 'direct' && thread.agentId === agentId,
155
+ )
156
+ return directThread?.threadId
157
+ })()
158
+ : threadId
159
+
160
+ if (!resolvedThreadId) return
161
+
162
+ const target = allThreadsRef.current.find((thread) => thread.threadId === resolvedThreadId)
163
+ const nextAgentId = target?.mode === 'direct' && target.agentId ? target.agentId : null
164
+ setCurrentThreadId(resolvedThreadId)
165
+ setSelectedAgentId(nextAgentId)
166
+ },
167
+ [placeholderThreadPrefix, setCurrentThreadId, setSelectedAgentId],
168
+ )
169
+
170
+ const handleCreateNewThread = useCallback(async () => {
171
+ if (isCreatingThread) return
172
+
173
+ try {
174
+ const existingDraftThreadId = resolveReusableThreadId(threadsRef.current)
175
+ if (existingDraftThreadId) {
176
+ setCurrentThreadId(existingDraftThreadId)
177
+ setSelectedAgentId(null)
178
+ return
179
+ }
180
+
181
+ const result = await createThreadRef.current()
182
+ setCurrentThreadId(result.id)
183
+ setSelectedAgentId(null)
184
+ } catch (error) {
185
+ notifyBackgroundError?.(error, 'Failed to create thread.')
186
+ throw error
187
+ }
188
+ }, [isCreatingThread, notifyBackgroundError, resolveReusableThreadId, setCurrentThreadId, setSelectedAgentId])
189
+
190
+ const handleRenameThread = useCallback(async (threadId: string, newTitle: string) => {
191
+ const target = allThreadsRef.current.find((thread) => thread.threadId === threadId)
192
+ if (!target || !target.isMutable) return
193
+ await renameThreadRef.current({ threadId, title: newTitle })
194
+ }, [])
195
+
196
+ const handleArchiveThread = useCallback(
197
+ async (threadId: string) => {
198
+ const target = allThreadsRef.current.find((thread) => thread.threadId === threadId)
199
+ if (!target || !target.isMutable) return
200
+ await archiveThreadRef.current(threadId)
201
+ if (currentThreadIdRef.current === threadId) {
202
+ const remaining = threadsRef.current.filter((thread) => thread.threadId !== threadId)
203
+ if (remaining.length > 0) {
204
+ handleSwitchThread(remaining[0].threadId)
205
+ } else {
206
+ setCurrentThreadId(null)
207
+ setSelectedAgentId(null)
208
+ }
209
+ }
210
+ },
211
+ [handleSwitchThread, setCurrentThreadId, setSelectedAgentId],
212
+ )
213
+
214
+ const handleUnarchiveThread = useCallback(async (threadId: string) => {
215
+ const target = allThreadsRef.current.find((thread) => thread.threadId === threadId)
216
+ if (!target || !target.isMutable) return
217
+ await unarchiveThreadRef.current(threadId)
218
+ }, [])
219
+
220
+ const handleDeleteThread = useCallback(
221
+ async (threadId: string) => {
222
+ const target = allThreadsRef.current.find((thread) => thread.threadId === threadId)
223
+ if (!target || !target.isMutable) return
224
+ await deleteThreadRef.current(threadId)
225
+ if (currentThreadIdRef.current === threadId) {
226
+ const remaining = threadsRef.current.filter((thread) => thread.threadId !== threadId)
227
+ if (remaining.length > 0) {
228
+ handleSwitchThread(remaining[0].threadId)
229
+ } else {
230
+ setCurrentThreadId(null)
231
+ setSelectedAgentId(null)
232
+ }
233
+ }
234
+ },
235
+ [handleSwitchThread, setCurrentThreadId, setSelectedAgentId],
236
+ )
237
+
238
+ useEffect(() => {
239
+ if (currentThreadId) return
240
+ if (isLoadingThreads) return
241
+
242
+ if (threads.length > 0) {
243
+ const preferredThread =
244
+ primaryDirectAgentId === undefined
245
+ ? threads[0]
246
+ : (threads.find((thread) => thread.mode === 'direct' && thread.agentId === primaryDirectAgentId) ??
247
+ threads[0])
248
+ setCurrentThreadId(preferredThread.threadId)
249
+ setSelectedAgentId(preferredThread.mode === 'direct' && preferredThread.agentId ? preferredThread.agentId : null)
250
+ return
251
+ }
252
+
253
+ if (isCreatingThreadRef.current) return
254
+ if (isCreatingThread) return
255
+ isCreatingThreadRef.current = true
256
+
257
+ handleCreateNewThread()
258
+ .catch(() => {})
259
+ .finally(() => {
260
+ isCreatingThreadRef.current = false
261
+ })
262
+ }, [
263
+ currentThreadId,
264
+ handleCreateNewThread,
265
+ isCreatingThread,
266
+ isLoadingThreads,
267
+ primaryDirectAgentId,
268
+ setCurrentThreadId,
269
+ setSelectedAgentId,
270
+ threads,
271
+ ])
272
+
273
+ useEffect(() => {
274
+ if (!currentThreadId?.startsWith(placeholderThreadPrefix)) return
275
+
276
+ const agentId = currentThreadId.slice(placeholderThreadPrefix.length) as TAgentId
277
+ const directThread = allThreads.find((thread) => thread.mode === 'direct' && thread.agentId === agentId)
278
+ if (!directThread) return
279
+
280
+ setCurrentThreadId(directThread.threadId)
281
+ setSelectedAgentId(directThread.agentId ?? null)
282
+ }, [allThreads, currentThreadId, placeholderThreadPrefix, setCurrentThreadId, setSelectedAgentId])
283
+
284
+ useEffect(() => {
285
+ if (currentThreadId !== null || selectedAgentId === null) return
286
+
287
+ const directThread = allThreads.find((thread) => thread.mode === 'direct' && thread.agentId === selectedAgentId)
288
+ if (!directThread) return
289
+
290
+ setCurrentThreadId(directThread.threadId)
291
+ }, [allThreads, currentThreadId, selectedAgentId, setCurrentThreadId])
292
+
293
+ useEffect(() => {
294
+ if (!currentThreadId) {
295
+ if (selectedAgentId !== null) {
296
+ setSelectedAgentId(null)
297
+ }
298
+ return
299
+ }
300
+
301
+ const activeThread = allThreads.find((thread) => thread.threadId === currentThreadId)
302
+ if (!activeThread) return
303
+
304
+ const expectedAgentId = activeThread.mode === 'direct' && activeThread.agentId ? activeThread.agentId : null
305
+ if (selectedAgentId !== expectedAgentId) {
306
+ setSelectedAgentId(expectedAgentId)
307
+ }
308
+ }, [allThreads, currentThreadId, selectedAgentId, setSelectedAgentId])
309
+
310
+ return {
311
+ threads,
312
+ coreThreads,
313
+ archivedThreads,
314
+ currentThreadId,
315
+ selectedAgentId,
316
+ isLoadingThreads,
317
+ isRefetchingThreads,
318
+ hasMoreThreads: enabled ? hasMoreThreads : false,
319
+ isFetchingNextThreadsPage,
320
+ fetchNextThreadsPage,
321
+ handleCreateNewThread,
322
+ handleSwitchThread,
323
+ handleRenameThread,
324
+ handleArchiveThread,
325
+ handleUnarchiveThread,
326
+ handleDeleteThread,
327
+ }
328
+ }
@@ -0,0 +1,80 @@
1
+ import type { SerializableExecutionPlanTask } from '@lota-sdk/shared/schemas/execution-plan'
2
+ import type { ExecutionPlanToolResultData } from '@lota-sdk/shared/schemas/tools'
3
+
4
+ export function getLatestExecutionPlanResult(output: unknown): ExecutionPlanToolResultData | null {
5
+ if (output && typeof output === 'object' && 'hasPlan' in output) {
6
+ return output as ExecutionPlanToolResultData
7
+ }
8
+
9
+ if (Array.isArray(output)) {
10
+ for (let index = output.length - 1; index >= 0; index -= 1) {
11
+ const candidate = getLatestExecutionPlanResult(output[index])
12
+ if (candidate) return candidate
13
+ }
14
+ }
15
+
16
+ return null
17
+ }
18
+
19
+ export function getExecutionPlanActionLabel(action: string, isRunning: boolean): string {
20
+ if (isRunning) return 'Updating execution plan'
21
+
22
+ switch (action) {
23
+ case 'created':
24
+ return 'Execution plan created'
25
+ case 'replaced':
26
+ return 'Execution plan replaced'
27
+ case 'task-status-updated':
28
+ return 'Execution plan updated'
29
+ case 'task-restarted':
30
+ return 'Execution task restarted'
31
+ case 'loaded':
32
+ return 'Execution plan loaded'
33
+ default:
34
+ return 'Execution plan'
35
+ }
36
+ }
37
+
38
+ export function getExecutionPlanStatusLabel(status: string | null | undefined): string {
39
+ if (status === 'draft') return 'Draft'
40
+ if (status === 'executing') return 'Executing'
41
+ if (status === 'blocked') return 'Blocked'
42
+ if (status === 'completed') return 'Completed'
43
+ if (status === 'aborted') return 'Aborted'
44
+ return 'Idle'
45
+ }
46
+
47
+ export function getExecutionPlanOwnerLabel(
48
+ task: SerializableExecutionPlanTask,
49
+ displayNameByOwnerRef: Record<string, string> = {},
50
+ ): string {
51
+ if (task.ownerType === 'agent' && task.ownerRef in displayNameByOwnerRef) {
52
+ return displayNameByOwnerRef[task.ownerRef]
53
+ }
54
+ if (task.ownerType === 'user') return 'User'
55
+ return task.ownerRef
56
+ }
57
+
58
+ export function buildExecutionPlanToolViewModel(params: { input: unknown; isRunning: boolean; output: unknown }) {
59
+ const result = getLatestExecutionPlanResult(params.output)
60
+ const plan = result?.plan ?? null
61
+ const inputTitle =
62
+ params.input &&
63
+ typeof params.input === 'object' &&
64
+ 'title' in params.input &&
65
+ typeof params.input.title === 'string'
66
+ ? params.input.title.trim()
67
+ : ''
68
+ const title = plan && plan.title.trim() ? plan.title.trim() : inputTitle || 'Execution plan'
69
+
70
+ return {
71
+ actionLabel: getExecutionPlanActionLabel(result?.action ?? 'none', params.isRunning),
72
+ hasPlan: plan !== null,
73
+ latestEvent: plan ? plan.recentEvents.at(-1)?.message.trim() : undefined,
74
+ plan,
75
+ progressSummary: plan ? `${plan.progress.completed}/${plan.progress.total} complete` : 'No active plan',
76
+ result,
77
+ statusLabel: getExecutionPlanStatusLabel(result?.status ?? plan?.status),
78
+ title,
79
+ }
80
+ }
@@ -0,0 +1,3 @@
1
+ export * from './execution-plan'
2
+ export * from './tool-registry'
3
+ export * from './user-questions'
@@ -0,0 +1,22 @@
1
+ export type ToolRenderMode = 'framed' | 'standalone'
2
+
3
+ export type ToolRegistryEntry<TComponent> = { component: TComponent; renderMode?: ToolRenderMode }
4
+
5
+ export function createToolRegistry<const TRegistry extends Record<string, ToolRegistryEntry<unknown>>>(
6
+ registry: TRegistry,
7
+ ) {
8
+ type ToolName = keyof TRegistry & string
9
+
10
+ return {
11
+ registry,
12
+ isRegisteredTool(toolName: string): toolName is ToolName {
13
+ return toolName in registry
14
+ },
15
+ getToolComponent<TName extends ToolName>(toolName: TName): TRegistry[TName]['component'] | undefined {
16
+ return registry[toolName]?.component
17
+ },
18
+ resolveToolRenderMode(toolName: ToolName): ToolRenderMode {
19
+ return registry[toolName]?.renderMode ?? 'framed'
20
+ },
21
+ }
22
+ }