@makemore/agent-frontend 1.8.0 → 2.0.0

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,106 @@
1
+ /**
2
+ * MessageList component - renders the scrollable message container
3
+ */
4
+
5
+ import { html } from 'htm/preact';
6
+ import { useRef, useEffect } from 'preact/hooks';
7
+ import { Message } from './Message.js';
8
+ import { escapeHtml } from '../utils/helpers.js';
9
+
10
+ export function MessageList({
11
+ messages,
12
+ isLoading,
13
+ hasMoreMessages,
14
+ loadingMoreMessages,
15
+ onLoadMore,
16
+ debugMode,
17
+ markdownParser,
18
+ emptyStateTitle,
19
+ emptyStateMessage,
20
+ }) {
21
+ const containerRef = useRef(null);
22
+ const shouldAutoScrollRef = useRef(true);
23
+
24
+ // Track if user scrolls away from bottom
25
+ const handleScroll = (e) => {
26
+ const el = e.target;
27
+ const isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 100;
28
+ shouldAutoScrollRef.current = isNearBottom;
29
+
30
+ // Load more when scrolling to top
31
+ if (el.scrollTop < 50 && hasMoreMessages && !loadingMoreMessages) {
32
+ const prevScrollHeight = el.scrollHeight;
33
+ onLoadMore().then(() => {
34
+ // Maintain scroll position after loading
35
+ const newScrollHeight = el.scrollHeight;
36
+ el.scrollTop = newScrollHeight - prevScrollHeight + el.scrollTop;
37
+ });
38
+ }
39
+ };
40
+
41
+ // Auto-scroll to bottom when messages change or loading state changes
42
+ useEffect(() => {
43
+ const el = containerRef.current;
44
+ if (el && shouldAutoScrollRef.current) {
45
+ // Use requestAnimationFrame to ensure DOM has updated
46
+ requestAnimationFrame(() => {
47
+ el.scrollTop = el.scrollHeight;
48
+ });
49
+ }
50
+ }, [messages, isLoading]);
51
+
52
+ // Always scroll to bottom on initial load or when first message added
53
+ useEffect(() => {
54
+ const el = containerRef.current;
55
+ if (el && messages.length <= 2) {
56
+ shouldAutoScrollRef.current = true;
57
+ requestAnimationFrame(() => {
58
+ el.scrollTop = el.scrollHeight;
59
+ });
60
+ }
61
+ }, [messages.length]);
62
+
63
+ const isEmpty = messages.length === 0;
64
+
65
+ return html`
66
+ <div class="cw-messages" ref=${containerRef} onScroll=${handleScroll}>
67
+ ${isEmpty && html`
68
+ <div class="cw-empty-state">
69
+ <svg class="cw-empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
70
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
71
+ </svg>
72
+ <h3>${escapeHtml(emptyStateTitle)}</h3>
73
+ <p>${escapeHtml(emptyStateMessage)}</p>
74
+ </div>
75
+ `}
76
+
77
+ ${!isEmpty && hasMoreMessages && html`
78
+ <div class="cw-load-more" onClick=${onLoadMore}>
79
+ ${loadingMoreMessages
80
+ ? html`<span class="cw-spinner"></span><span>Loading...</span>`
81
+ : html`<span>↑ Scroll up or click to load older messages</span>`
82
+ }
83
+ </div>
84
+ `}
85
+
86
+ ${messages.map(msg => html`
87
+ <${Message}
88
+ key=${msg.id}
89
+ msg=${msg}
90
+ debugMode=${debugMode}
91
+ markdownParser=${markdownParser}
92
+ />
93
+ `)}
94
+
95
+ ${isLoading && html`
96
+ <div class="cw-message-row">
97
+ <div class="cw-typing">
98
+ <span class="cw-spinner"></span>
99
+ <span>Thinking...</span>
100
+ </div>
101
+ </div>
102
+ `}
103
+ </div>
104
+ `;
105
+ }
106
+
@@ -0,0 +1,68 @@
1
+ /**
2
+ * ModelSelector component - dropdown for selecting LLM model
3
+ */
4
+
5
+ import { html } from 'htm/preact';
6
+ import { useState } from 'preact/hooks';
7
+ import { escapeHtml } from '../utils/helpers.js';
8
+
9
+ export function ModelSelector({
10
+ availableModels,
11
+ selectedModel,
12
+ onSelectModel,
13
+ disabled
14
+ }) {
15
+ const [isOpen, setIsOpen] = useState(false);
16
+
17
+ if (!availableModels || availableModels.length === 0) {
18
+ return null;
19
+ }
20
+
21
+ const selectedModelInfo = availableModels.find(m => m.id === selectedModel);
22
+ const displayName = selectedModelInfo?.name || 'Select Model';
23
+
24
+ const handleToggle = () => {
25
+ if (!disabled) {
26
+ setIsOpen(!isOpen);
27
+ }
28
+ };
29
+
30
+ const handleSelect = (modelId) => {
31
+ onSelectModel(modelId);
32
+ setIsOpen(false);
33
+ };
34
+
35
+ return html`
36
+ <div class="cw-model-selector">
37
+ <button
38
+ class="cw-model-btn"
39
+ onClick=${handleToggle}
40
+ disabled=${disabled}
41
+ title="Select Model"
42
+ >
43
+ <span class="cw-model-icon">🤖</span>
44
+ <span class="cw-model-name">${escapeHtml(displayName)}</span>
45
+ <span class="cw-model-chevron">${isOpen ? '▲' : '▼'}</span>
46
+ </button>
47
+
48
+ ${isOpen && html`
49
+ <div class="cw-model-dropdown">
50
+ ${availableModels.map(model => html`
51
+ <button
52
+ key=${model.id}
53
+ class="cw-model-option ${model.id === selectedModel ? 'cw-model-option-selected' : ''}"
54
+ onClick=${() => handleSelect(model.id)}
55
+ >
56
+ <span class="cw-model-option-name">${escapeHtml(model.name)}</span>
57
+ <span class="cw-model-option-provider">${escapeHtml(model.provider)}</span>
58
+ ${model.description && html`
59
+ <span class="cw-model-option-desc">${escapeHtml(model.description)}</span>
60
+ `}
61
+ </button>
62
+ `)}
63
+ </div>
64
+ `}
65
+ </div>
66
+ `;
67
+ }
68
+
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Sidebar component - conversation list
3
+ */
4
+
5
+ import { html } from 'htm/preact';
6
+ import { escapeHtml, formatDate } from '../utils/helpers.js';
7
+
8
+ export function Sidebar({
9
+ isOpen,
10
+ conversations,
11
+ conversationsLoading,
12
+ currentConversationId,
13
+ onClose,
14
+ onNewConversation,
15
+ onSwitchConversation,
16
+ }) {
17
+ return html`
18
+ <div class="cw-sidebar ${isOpen ? 'cw-sidebar-open' : ''}">
19
+ <div class="cw-sidebar-header">
20
+ <span>Conversations</span>
21
+ <button class="cw-sidebar-close" onClick=${onClose}>✕</button>
22
+ </div>
23
+
24
+ <button class="cw-new-conversation" onClick=${onNewConversation}>
25
+ <span>+ New Conversation</span>
26
+ </button>
27
+
28
+ <div class="cw-conversation-list">
29
+ ${conversationsLoading && html`
30
+ <div class="cw-sidebar-loading">
31
+ <span class="cw-spinner"></span>
32
+ </div>
33
+ `}
34
+
35
+ ${!conversationsLoading && conversations.length === 0 && html`
36
+ <div class="cw-sidebar-empty">No conversations yet</div>
37
+ `}
38
+
39
+ ${conversations.map(conv => html`
40
+ <div
41
+ key=${conv.id}
42
+ class="cw-conversation-item ${conv.id === currentConversationId ? 'cw-conversation-active' : ''}"
43
+ onClick=${() => onSwitchConversation(conv.id)}
44
+ >
45
+ <div class="cw-conversation-title">${escapeHtml(conv.title || 'Untitled')}</div>
46
+ <div class="cw-conversation-date">${formatDate(conv.updatedAt || conv.createdAt)}</div>
47
+ </div>
48
+ `)}
49
+ </div>
50
+ </div>
51
+
52
+ <div
53
+ class="cw-sidebar-overlay ${isOpen ? 'cw-sidebar-overlay-visible' : ''}"
54
+ onClick=${onClose}
55
+ />
56
+ `;
57
+ }
58
+
@@ -0,0 +1,455 @@
1
+ /**
2
+ * Chat hook - manages messages, sending, and SSE streaming
3
+ */
4
+
5
+ import { useState, useCallback, useRef, useEffect } from 'preact/hooks';
6
+ import { generateId } from '../utils/helpers.js';
7
+
8
+ export function useChat(config, api, storage) {
9
+ const [messages, setMessages] = useState([]);
10
+ const [isLoading, setIsLoading] = useState(false);
11
+ const [error, setError] = useState(null);
12
+ const [conversationId, setConversationId] = useState(() => storage?.get(config.conversationIdKey) || null);
13
+ const [hasMoreMessages, setHasMoreMessages] = useState(false);
14
+ const [loadingMoreMessages, setLoadingMoreMessages] = useState(false);
15
+ const [messagesOffset, setMessagesOffset] = useState(0);
16
+
17
+ const eventSourceRef = useRef(null);
18
+ const currentRunIdRef = useRef(null);
19
+
20
+ // Save conversation ID to storage when it changes
21
+ useEffect(() => {
22
+ if (conversationId) {
23
+ storage?.set(config.conversationIdKey, conversationId);
24
+ }
25
+ }, [conversationId, config.conversationIdKey, storage]);
26
+
27
+ const subscribeToEvents = useCallback(async (runId, token, onAssistantMessage) => {
28
+ if (eventSourceRef.current) eventSourceRef.current.close();
29
+
30
+ const eventPath = config.apiPaths.runEvents.replace('{runId}', runId);
31
+ let url = `${config.backendUrl}${eventPath}`;
32
+ if (token) url += `?anonymous_token=${encodeURIComponent(token)}`;
33
+
34
+ const eventSource = new EventSource(url);
35
+ eventSourceRef.current = eventSource;
36
+ let assistantContent = '';
37
+
38
+ eventSource.addEventListener('assistant.message', (event) => {
39
+ try {
40
+ const data = JSON.parse(event.data);
41
+ if (config.onEvent) config.onEvent('assistant.message', data.payload);
42
+ const content = data.payload.content;
43
+ if (content) {
44
+ assistantContent += content;
45
+ setMessages(prev => {
46
+ const last = prev[prev.length - 1];
47
+ if (last?.role === 'assistant' && last.id.startsWith('assistant-stream-')) {
48
+ return [...prev.slice(0, -1), { ...last, content: assistantContent }];
49
+ }
50
+ return [...prev, {
51
+ id: 'assistant-stream-' + Date.now(),
52
+ role: 'assistant',
53
+ content: assistantContent,
54
+ timestamp: new Date(),
55
+ type: 'message',
56
+ }];
57
+ });
58
+ }
59
+ } catch (err) { console.error('[ChatWidget] Parse error:', err); }
60
+ });
61
+
62
+ eventSource.addEventListener('tool.call', (event) => {
63
+ try {
64
+ const data = JSON.parse(event.data);
65
+ if (config.onEvent) config.onEvent('tool.call', data.payload);
66
+ // Add tool call to messages
67
+ setMessages(prev => [...prev, {
68
+ id: 'tool-call-' + Date.now(),
69
+ role: 'assistant',
70
+ content: `🔧 ${data.payload.name}`,
71
+ timestamp: new Date(),
72
+ type: 'tool_call',
73
+ metadata: {
74
+ toolName: data.payload.name,
75
+ arguments: data.payload.arguments,
76
+ toolCallId: data.payload.id,
77
+ },
78
+ }]);
79
+ } catch (err) { console.error('[ChatWidget] Parse error:', err); }
80
+ });
81
+
82
+ eventSource.addEventListener('tool.result', (event) => {
83
+ try {
84
+ const data = JSON.parse(event.data);
85
+ if (config.onEvent) config.onEvent('tool.result', data.payload);
86
+ // Add tool result to messages
87
+ const result = data.payload.result;
88
+ const isError = result?.error;
89
+ setMessages(prev => [...prev, {
90
+ id: 'tool-result-' + Date.now(),
91
+ role: 'system',
92
+ content: isError ? `❌ ${result.error}` : `✓ Done`,
93
+ timestamp: new Date(),
94
+ type: 'tool_result',
95
+ metadata: {
96
+ toolName: data.payload.name,
97
+ result: result,
98
+ toolCallId: data.payload.tool_call_id,
99
+ },
100
+ }]);
101
+ } catch (err) { console.error('[ChatWidget] Parse error:', err); }
102
+ });
103
+
104
+ // Handle custom events (e.g., UI control from builder agent)
105
+ eventSource.addEventListener('custom', (event) => {
106
+ try {
107
+ const data = JSON.parse(event.data);
108
+ if (config.onEvent) config.onEvent('custom', data.payload);
109
+ // Custom events can be used for UI control, agent switching, etc.
110
+ if (data.payload?.type === 'ui_control') {
111
+ // Emit a special callback for UI control events
112
+ if (config.onUIControl) {
113
+ config.onUIControl(data.payload);
114
+ }
115
+ }
116
+ // Handle sub-agent context changes
117
+ if (data.payload?.type === 'agent_context') {
118
+ setMessages(prev => [...prev, {
119
+ id: 'agent-context-' + Date.now(),
120
+ role: 'system',
121
+ content: `🔗 ${data.payload.agent_name || 'Sub-agent'} is now handling this request`,
122
+ timestamp: new Date(),
123
+ type: 'agent_context',
124
+ metadata: {
125
+ agentKey: data.payload.agent_key,
126
+ agentName: data.payload.agent_name,
127
+ action: data.payload.action,
128
+ },
129
+ }]);
130
+ }
131
+ } catch (err) { console.error('[ChatWidget] Parse error:', err); }
132
+ });
133
+
134
+ // Handle sub-agent invocation events
135
+ eventSource.addEventListener('sub_agent.start', (event) => {
136
+ try {
137
+ const data = JSON.parse(event.data);
138
+ if (config.onEvent) config.onEvent('sub_agent.start', data.payload);
139
+ setMessages(prev => [...prev, {
140
+ id: 'sub-agent-start-' + Date.now(),
141
+ role: 'system',
142
+ content: `🔗 Delegating to ${data.payload.agent_name || data.payload.sub_agent_key || 'sub-agent'}...`,
143
+ timestamp: new Date(),
144
+ type: 'sub_agent_start',
145
+ metadata: {
146
+ subAgentKey: data.payload.sub_agent_key,
147
+ agentName: data.payload.agent_name,
148
+ invocationMode: data.payload.invocation_mode,
149
+ },
150
+ }]);
151
+ } catch (err) { console.error('[ChatWidget] Parse error:', err); }
152
+ });
153
+
154
+ eventSource.addEventListener('sub_agent.end', (event) => {
155
+ try {
156
+ const data = JSON.parse(event.data);
157
+ if (config.onEvent) config.onEvent('sub_agent.end', data.payload);
158
+ setMessages(prev => [...prev, {
159
+ id: 'sub-agent-end-' + Date.now(),
160
+ role: 'system',
161
+ content: `✓ ${data.payload.agent_name || 'Sub-agent'} completed`,
162
+ timestamp: new Date(),
163
+ type: 'sub_agent_end',
164
+ metadata: {
165
+ subAgentKey: data.payload.sub_agent_key,
166
+ agentName: data.payload.agent_name,
167
+ },
168
+ }]);
169
+ } catch (err) { console.error('[ChatWidget] Parse error:', err); }
170
+ });
171
+
172
+ const handleTerminal = (event) => {
173
+ try {
174
+ const data = JSON.parse(event.data);
175
+ if (config.onEvent) config.onEvent(data.type, data.payload);
176
+ if (data.type === 'run.failed') {
177
+ const errMsg = data.payload.error || 'Agent run failed';
178
+ setError(errMsg);
179
+ setMessages(prev => [...prev, {
180
+ id: 'error-' + Date.now(),
181
+ role: 'system',
182
+ content: `❌ Error: ${errMsg}`,
183
+ timestamp: new Date(),
184
+ type: 'error',
185
+ }]);
186
+ }
187
+ } catch (err) { console.error('[ChatWidget] Parse error:', err); }
188
+ setIsLoading(false);
189
+ eventSource.close();
190
+ eventSourceRef.current = null;
191
+ if (assistantContent && onAssistantMessage) {
192
+ onAssistantMessage(assistantContent);
193
+ }
194
+ };
195
+
196
+ eventSource.addEventListener('run.succeeded', handleTerminal);
197
+ eventSource.addEventListener('run.failed', handleTerminal);
198
+ eventSource.addEventListener('run.cancelled', handleTerminal);
199
+ eventSource.addEventListener('run.timed_out', handleTerminal);
200
+
201
+ eventSource.onerror = () => {
202
+ setIsLoading(false);
203
+ eventSource.close();
204
+ eventSourceRef.current = null;
205
+ };
206
+ }, [config]);
207
+
208
+ const sendMessage = useCallback(async (content, options = {}) => {
209
+ if (!content.trim() || isLoading) return;
210
+
211
+ const { model, onAssistantMessage } = typeof options === 'function'
212
+ ? { onAssistantMessage: options }
213
+ : options;
214
+
215
+ setIsLoading(true);
216
+ setError(null);
217
+
218
+ const userMessage = {
219
+ id: generateId(),
220
+ role: 'user',
221
+ content: content.trim(),
222
+ timestamp: new Date(),
223
+ type: 'message',
224
+ };
225
+ setMessages(prev => [...prev, userMessage]);
226
+
227
+ try {
228
+ const token = await api.getOrCreateSession();
229
+
230
+ const requestBody = {
231
+ agentKey: config.agentKey,
232
+ conversationId: conversationId,
233
+ messages: [{ role: 'user', content: content.trim() }],
234
+ metadata: { ...config.metadata, journeyType: config.defaultJourneyType },
235
+ };
236
+
237
+ // Include model if specified
238
+ if (model) {
239
+ requestBody.model = model;
240
+ }
241
+
242
+ const response = await fetch(`${config.backendUrl}${config.apiPaths.runs}`, api.getFetchOptions({
243
+ method: 'POST',
244
+ headers: { 'Content-Type': 'application/json' },
245
+ body: JSON.stringify(requestBody),
246
+ }));
247
+
248
+ if (!response.ok) {
249
+ const errorData = await response.json().catch(() => ({}));
250
+ throw new Error(errorData.error || `HTTP ${response.status}`);
251
+ }
252
+
253
+ const run = await response.json();
254
+ currentRunIdRef.current = run.id;
255
+ const runConversationId = run.conversationId || run.conversation_id;
256
+ if (!conversationId && runConversationId) {
257
+ setConversationId(runConversationId);
258
+ }
259
+
260
+ await subscribeToEvents(run.id, token, onAssistantMessage);
261
+ } catch (err) {
262
+ setError(err.message || 'Failed to send message');
263
+ setIsLoading(false);
264
+ } finally {
265
+ currentRunIdRef.current = null;
266
+ }
267
+ }, [config, api, conversationId, isLoading, subscribeToEvents]);
268
+
269
+ const cancelRun = useCallback(async () => {
270
+ const runId = currentRunIdRef.current;
271
+ if (!runId || !isLoading) return;
272
+
273
+ try {
274
+ // Build the cancel URL - replace {runId} placeholder or append to runs path
275
+ const cancelPath = config.apiPaths.cancelRun
276
+ ? config.apiPaths.cancelRun.replace('{runId}', runId)
277
+ : `${config.apiPaths.runs}${runId}/cancel/`;
278
+
279
+ const response = await fetch(`${config.backendUrl}${cancelPath}`, api.getFetchOptions({
280
+ method: 'POST',
281
+ headers: { 'Content-Type': 'application/json' },
282
+ }));
283
+
284
+ if (response.ok) {
285
+ // Close the event source immediately
286
+ if (eventSourceRef.current) {
287
+ eventSourceRef.current.close();
288
+ eventSourceRef.current = null;
289
+ }
290
+ setIsLoading(false);
291
+ currentRunIdRef.current = null;
292
+
293
+ // Add a cancelled message
294
+ setMessages(prev => [...prev, {
295
+ id: 'cancelled-' + Date.now(),
296
+ role: 'system',
297
+ content: '⏹ Run cancelled',
298
+ timestamp: new Date(),
299
+ type: 'cancelled',
300
+ }]);
301
+ }
302
+ } catch (err) {
303
+ console.error('[ChatWidget] Failed to cancel run:', err);
304
+ }
305
+ }, [config, api, isLoading]);
306
+
307
+ const clearMessages = useCallback(() => {
308
+ setMessages([]);
309
+ setConversationId(null);
310
+ setError(null);
311
+ setHasMoreMessages(false);
312
+ setMessagesOffset(0);
313
+ storage?.set(config.conversationIdKey, null);
314
+ }, [config.conversationIdKey, storage]);
315
+
316
+ // Map API message format to internal message format with proper types
317
+ const mapApiMessage = (m) => {
318
+ const base = {
319
+ id: generateId(),
320
+ role: m.role,
321
+ timestamp: m.timestamp ? new Date(m.timestamp) : new Date(),
322
+ };
323
+
324
+ // Tool result messages (role: "tool")
325
+ if (m.role === 'tool') {
326
+ return {
327
+ ...base,
328
+ role: 'system',
329
+ content: '✓ Done',
330
+ type: 'tool_result',
331
+ metadata: {
332
+ result: m.content,
333
+ toolCallId: m.tool_call_id,
334
+ },
335
+ };
336
+ }
337
+
338
+ // Assistant messages with tool calls
339
+ if (m.role === 'assistant' && m.tool_calls && m.tool_calls.length > 0) {
340
+ // Return an array of tool call messages (will be flattened)
341
+ return m.tool_calls.map(tc => ({
342
+ id: generateId(),
343
+ role: 'assistant',
344
+ content: `🔧 ${tc.function?.name || tc.name || 'tool'}`,
345
+ timestamp: base.timestamp,
346
+ type: 'tool_call',
347
+ metadata: {
348
+ toolName: tc.function?.name || tc.name,
349
+ arguments: tc.function?.arguments || tc.arguments,
350
+ toolCallId: tc.id,
351
+ },
352
+ }));
353
+ }
354
+
355
+ // Skip empty assistant messages (e.g., tool-call-only responses)
356
+ const content = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
357
+ if (m.role === 'assistant' && !content?.trim()) {
358
+ return null; // Will be filtered out
359
+ }
360
+
361
+ // Regular messages
362
+ return {
363
+ ...base,
364
+ content,
365
+ type: 'message',
366
+ };
367
+ };
368
+
369
+ const loadConversation = useCallback(async (convId) => {
370
+ setIsLoading(true);
371
+ setMessages([]);
372
+ setConversationId(convId);
373
+
374
+ try {
375
+ const token = await api.getOrCreateSession();
376
+ const limit = 10;
377
+ const url = `${config.backendUrl}${config.apiPaths.conversations}${convId}/?limit=${limit}&offset=0`;
378
+
379
+ const response = await fetch(url, api.getFetchOptions({ method: 'GET' }));
380
+
381
+ if (response.ok) {
382
+ const conversation = await response.json();
383
+ if (conversation.messages) {
384
+ // Use flatMap to handle tool_calls which return arrays, filter out nulls (empty messages)
385
+ setMessages(conversation.messages.flatMap(mapApiMessage).filter(Boolean));
386
+ }
387
+ setHasMoreMessages(conversation.has_more || conversation.hasMore || false);
388
+ setMessagesOffset(conversation.messages?.length || 0);
389
+ } else if (response.status === 404) {
390
+ setConversationId(null);
391
+ storage?.set(config.conversationIdKey, null);
392
+ }
393
+ } catch (err) {
394
+ console.error('[ChatWidget] Failed to load conversation:', err);
395
+ } finally {
396
+ setIsLoading(false);
397
+ }
398
+ }, [config, api, storage]);
399
+
400
+ const loadMoreMessages = useCallback(async () => {
401
+ if (!conversationId || loadingMoreMessages || !hasMoreMessages) return;
402
+
403
+ setLoadingMoreMessages(true);
404
+
405
+ try {
406
+ const limit = 10;
407
+ const url = `${config.backendUrl}${config.apiPaths.conversations}${conversationId}/?limit=${limit}&offset=${messagesOffset}`;
408
+
409
+ const response = await fetch(url, api.getFetchOptions({ method: 'GET' }));
410
+
411
+ if (response.ok) {
412
+ const conversation = await response.json();
413
+ if (conversation.messages?.length > 0) {
414
+ // Use flatMap to handle tool_calls which return arrays, filter out nulls (empty messages)
415
+ const olderMessages = conversation.messages.flatMap(mapApiMessage).filter(Boolean);
416
+ setMessages(prev => [...olderMessages, ...prev]);
417
+ // Use original message count for offset, not flattened count
418
+ setMessagesOffset(prev => prev + conversation.messages.length);
419
+ setHasMoreMessages(conversation.has_more || conversation.hasMore || false);
420
+ } else {
421
+ setHasMoreMessages(false);
422
+ }
423
+ }
424
+ } catch (err) {
425
+ console.error('[ChatWidget] Failed to load more messages:', err);
426
+ } finally {
427
+ setLoadingMoreMessages(false);
428
+ }
429
+ }, [config, api, conversationId, messagesOffset, loadingMoreMessages, hasMoreMessages]);
430
+
431
+ // Cleanup on unmount
432
+ useEffect(() => {
433
+ return () => {
434
+ if (eventSourceRef.current) {
435
+ eventSourceRef.current.close();
436
+ }
437
+ };
438
+ }, []);
439
+
440
+ return {
441
+ messages,
442
+ isLoading,
443
+ error,
444
+ conversationId,
445
+ hasMoreMessages,
446
+ loadingMoreMessages,
447
+ sendMessage,
448
+ cancelRun,
449
+ clearMessages,
450
+ loadConversation,
451
+ loadMoreMessages,
452
+ setConversationId,
453
+ };
454
+ }
455
+