@makemore/agent-frontend 2.4.0 → 2.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makemore/agent-frontend",
3
- "version": "2.4.0",
3
+ "version": "2.7.0",
4
4
  "description": "A lightweight chat widget for AI agents built with Preact. Embed conversational AI into any website with a single script tag.",
5
5
  "type": "module",
6
6
  "main": "dist/chat-widget.js",
@@ -9,8 +9,10 @@ import { MessageList } from './MessageList.js';
9
9
  import { InputForm } from './InputForm.js';
10
10
  import { Sidebar } from './Sidebar.js';
11
11
  import { ModelSelector } from './ModelSelector.js';
12
+ import { TaskList } from './TaskList.js';
12
13
  import { useChat } from '../hooks/useChat.js';
13
14
  import { useModels } from '../hooks/useModels.js';
15
+ import { useTasks } from '../hooks/useTasks.js';
14
16
  import { createApiClient } from '../utils/api.js';
15
17
  import { createStorage } from '../utils/helpers.js';
16
18
 
@@ -21,6 +23,7 @@ export function ChatWidget({ config, onStateChange, markdownParser, apiRef }) {
21
23
  const [debugMode, setDebugMode] = useState(false);
22
24
  const [sidebarOpen, setSidebarOpen] = useState(false);
23
25
  const [conversations, setConversations] = useState([]);
26
+ const [activeTab, setActiveTab] = useState('chat'); // 'chat' or 'tasks'
24
27
  const [conversationsLoading, setConversationsLoading] = useState(false);
25
28
 
26
29
  // TTS state
@@ -57,6 +60,9 @@ export function ChatWidget({ config, onStateChange, markdownParser, apiRef }) {
57
60
  // Models hook
58
61
  const models = useModels(config, api, storage);
59
62
 
63
+ // Tasks hook
64
+ const tasks = useTasks(config, api);
65
+
60
66
  // Track current agent from messages (for multi-agent systems)
61
67
  useEffect(() => {
62
68
  // Look for the most recent sub_agent_start or sub_agent_end message
@@ -152,6 +158,14 @@ export function ChatWidget({ config, onStateChange, markdownParser, apiRef }) {
152
158
  });
153
159
  }, [chat, enableTTS, models.selectedModel]);
154
160
 
161
+ // Handle tab switching
162
+ const handleTabChange = useCallback((tab) => {
163
+ setActiveTab(tab);
164
+ if (tab === 'tasks') {
165
+ tasks.loadTaskList();
166
+ }
167
+ }, [tasks]);
168
+
155
169
  // Expose imperative API to parent
156
170
  useEffect(() => {
157
171
  if (apiRef) {
@@ -221,39 +235,71 @@ export function ChatWidget({ config, onStateChange, markdownParser, apiRef }) {
221
235
  onToggleSidebar=${handleToggleSidebar}
222
236
  />
223
237
 
238
+ ${config.showTasksTab !== false && html`
239
+ <div class="cw-tabs">
240
+ <button
241
+ class=${`cw-tab ${activeTab === 'chat' ? 'cw-tab-active' : ''}`}
242
+ onClick=${() => handleTabChange('chat')}
243
+ >
244
+ Chat
245
+ </button>
246
+ <button
247
+ class=${`cw-tab ${activeTab === 'tasks' ? 'cw-tab-active' : ''}`}
248
+ onClick=${() => handleTabChange('tasks')}
249
+ >
250
+ Tasks ${tasks.progress.total > 0 ? html`<span class="cw-tab-badge">${tasks.progress.completed}/${tasks.progress.total}</span>` : ''}
251
+ </button>
252
+ </div>
253
+ `}
254
+
224
255
  ${debugMode && html`<div class="cw-status-bar"><span>🐛 Debug</span></div>`}
225
256
 
226
- <${MessageList}
227
- messages=${chat.messages}
228
- isLoading=${chat.isLoading}
229
- hasMoreMessages=${chat.hasMoreMessages}
230
- loadingMoreMessages=${chat.loadingMoreMessages}
231
- onLoadMore=${chat.loadMoreMessages}
232
- debugMode=${debugMode}
233
- markdownParser=${markdownParser}
234
- emptyStateTitle=${config.emptyStateTitle}
235
- emptyStateMessage=${config.emptyStateMessage}
236
- />
257
+ ${activeTab === 'chat' ? html`
258
+ <${MessageList}
259
+ messages=${chat.messages}
260
+ isLoading=${chat.isLoading}
261
+ hasMoreMessages=${chat.hasMoreMessages}
262
+ loadingMoreMessages=${chat.loadingMoreMessages}
263
+ onLoadMore=${chat.loadMoreMessages}
264
+ onEditMessage=${chat.editMessage}
265
+ onRetryMessage=${chat.retryMessage}
266
+ debugMode=${debugMode}
267
+ markdownParser=${markdownParser}
268
+ emptyStateTitle=${config.emptyStateTitle}
269
+ emptyStateMessage=${config.emptyStateMessage}
270
+ />
237
271
 
238
- ${chat.error && html`<div class="cw-error-bar">${chat.error}</div>`}
272
+ ${chat.error && html`<div class="cw-error-bar">${chat.error}</div>`}
239
273
 
240
- ${config.showModelSelector && models.availableModels.length > 0 && html`
241
- <${ModelSelector}
242
- availableModels=${models.availableModels}
243
- selectedModel=${models.selectedModel}
244
- onSelectModel=${models.selectModel}
245
- disabled=${chat.isLoading}
274
+ ${config.showModelSelector && models.availableModels.length > 0 && html`
275
+ <${ModelSelector}
276
+ availableModels=${models.availableModels}
277
+ selectedModel=${models.selectedModel}
278
+ onSelectModel=${models.selectModel}
279
+ disabled=${chat.isLoading}
280
+ />
281
+ `}
282
+
283
+ <${InputForm}
284
+ onSend=${handleSend}
285
+ onCancel=${chat.cancelRun}
286
+ isLoading=${chat.isLoading}
287
+ placeholder=${config.placeholder}
288
+ primaryColor=${config.primaryColor}
289
+ enableVoice=${config.enableVoice}
290
+ />
291
+ ` : html`
292
+ <${TaskList}
293
+ tasks=${tasks.tasks}
294
+ progress=${tasks.progress}
295
+ isLoading=${tasks.isLoading}
296
+ error=${tasks.error}
297
+ onUpdate=${tasks.updateTask}
298
+ onRemove=${tasks.removeTask}
299
+ onClear=${tasks.clearTasks}
300
+ onRefresh=${tasks.loadTaskList}
246
301
  />
247
302
  `}
248
-
249
- <${InputForm}
250
- onSend=${handleSend}
251
- onCancel=${chat.cancelRun}
252
- isLoading=${chat.isLoading}
253
- placeholder=${config.placeholder}
254
- primaryColor=${config.primaryColor}
255
- enableVoice=${config.enableVoice}
256
- />
257
303
  </div>
258
304
  `;
259
305
  }
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { html } from 'htm/preact';
6
- import { useState } from 'preact/hooks';
6
+ import { useState, useRef, useEffect } from 'preact/hooks';
7
7
  import { escapeHtml, parseMarkdown, formatFileSize, getFileTypeIcon } from '../utils/helpers.js';
8
8
 
9
9
  // Debug payload viewer component
@@ -26,9 +26,103 @@ function DebugPayload({ msg, show, onToggle }) {
26
26
  `;
27
27
  }
28
28
 
29
- export function Message({ msg, debugMode, markdownParser }) {
29
+ // Edit/Retry action buttons for user messages
30
+ function MessageActions({ onEdit, onRetry, isLoading, position, showEdit = true }) {
31
+ if (isLoading) return null;
32
+
33
+ return html`
34
+ <div class="cw-message-actions cw-message-actions-${position || 'left'}">
35
+ ${showEdit && html`
36
+ <button
37
+ class="cw-message-action-btn"
38
+ onClick=${onEdit}
39
+ title="Edit message"
40
+ >
41
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
42
+ <path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path>
43
+ </svg>
44
+ </button>
45
+ `}
46
+ <button
47
+ class="cw-message-action-btn"
48
+ onClick=${onRetry}
49
+ title="Retry from here"
50
+ >
51
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
52
+ <path d="M21 2v6h-6"></path>
53
+ <path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
54
+ <path d="M3 22v-6h6"></path>
55
+ <path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
56
+ </svg>
57
+ </button>
58
+ </div>
59
+ `;
60
+ }
61
+
62
+ // Inline edit form for editing a message
63
+ function InlineEditForm({ initialContent, onSave, onCancel }) {
64
+ const [content, setContent] = useState(initialContent);
65
+ const textareaRef = useRef(null);
66
+
67
+ useEffect(() => {
68
+ if (textareaRef.current) {
69
+ textareaRef.current.focus();
70
+ textareaRef.current.setSelectionRange(content.length, content.length);
71
+ // Auto-resize
72
+ textareaRef.current.style.height = 'auto';
73
+ textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
74
+ }
75
+ }, []);
76
+
77
+ const handleInput = (e) => {
78
+ setContent(e.target.value);
79
+ // Auto-resize textarea
80
+ e.target.style.height = 'auto';
81
+ e.target.style.height = e.target.scrollHeight + 'px';
82
+ };
83
+
84
+ const handleKeyDown = (e) => {
85
+ if (e.key === 'Enter' && !e.shiftKey) {
86
+ e.preventDefault();
87
+ if (content.trim()) {
88
+ onSave(content.trim());
89
+ }
90
+ } else if (e.key === 'Escape') {
91
+ onCancel();
92
+ }
93
+ };
94
+
95
+ return html`
96
+ <div class="cw-inline-edit">
97
+ <textarea
98
+ ref=${textareaRef}
99
+ class="cw-inline-edit-input"
100
+ value=${content}
101
+ onInput=${handleInput}
102
+ onKeyDown=${handleKeyDown}
103
+ rows="1"
104
+ />
105
+ <div class="cw-inline-edit-actions">
106
+ <button
107
+ class="cw-inline-edit-btn cw-inline-edit-cancel"
108
+ onClick=${onCancel}
109
+ title="Cancel (Esc)"
110
+ >Cancel</button>
111
+ <button
112
+ class="cw-inline-edit-btn cw-inline-edit-save"
113
+ onClick=${() => content.trim() && onSave(content.trim())}
114
+ disabled=${!content.trim()}
115
+ title="Save & Resend (Enter)"
116
+ >Save & Send</button>
117
+ </div>
118
+ </div>
119
+ `;
120
+ }
121
+
122
+ export function Message({ msg, debugMode, markdownParser, onEdit, onRetry, isLoading, messageIndex }) {
30
123
  const [expanded, setExpanded] = useState(false);
31
124
  const [showPayload, setShowPayload] = useState(false);
125
+ const [isEditing, setIsEditing] = useState(false);
32
126
 
33
127
  const isUser = msg.role === 'user';
34
128
  const isSystem = msg.role === 'system';
@@ -139,10 +233,68 @@ export function Message({ msg, debugMode, markdownParser }) {
139
233
  `;
140
234
  };
141
235
 
236
+ // Handle edit save - always triggers a new run with the (possibly edited) content
237
+ const handleEditSave = (newContent) => {
238
+ setIsEditing(false);
239
+ if (onEdit) {
240
+ onEdit(messageIndex, newContent);
241
+ }
242
+ };
243
+
244
+ // Handle retry (resend same message)
245
+ const handleRetry = () => {
246
+ if (onRetry) {
247
+ onRetry(messageIndex);
248
+ }
249
+ };
250
+
251
+ // Show inline edit form for user messages in edit mode
252
+ if (isUser && isEditing) {
253
+ return html`
254
+ <div class=${rowClasses} style="position: relative;">
255
+ ${renderAttachments()}
256
+ <${InlineEditForm}
257
+ initialContent=${msg.content}
258
+ onSave=${handleEditSave}
259
+ onCancel=${() => setIsEditing(false)}
260
+ />
261
+ </div>
262
+ `;
263
+ }
264
+
265
+ // Show edit/retry actions for user messages (on left side since user messages are on right)
266
+ const showUserActions = isUser && onEdit && onRetry;
267
+ // Show retry-only for assistant messages (below the message)
268
+ const isAssistant = msg.role === 'assistant';
269
+ const showAssistantRetry = isAssistant && onRetry && !isLoading;
270
+ const hasActions = showUserActions || showAssistantRetry;
271
+
142
272
  return html`
143
- <div class=${rowClasses} style="position: relative;">
273
+ <div class="${rowClasses} ${hasActions ? 'cw-message-row-with-actions' : ''}">
144
274
  ${renderAttachments()}
145
- <div class=${classes} dangerouslySetInnerHTML=${{ __html: content }} />
275
+ ${showUserActions && html`
276
+ <div class="cw-user-actions-wrapper">
277
+ <${MessageActions}
278
+ onEdit=${() => setIsEditing(true)}
279
+ onRetry=${handleRetry}
280
+ isLoading=${isLoading}
281
+ position="left"
282
+ showEdit=${true}
283
+ />
284
+ <div class=${classes} dangerouslySetInnerHTML=${{ __html: content }} />
285
+ </div>
286
+ `}
287
+ ${!showUserActions && html`
288
+ <div class=${classes} dangerouslySetInnerHTML=${{ __html: content }} />
289
+ `}
290
+ ${showAssistantRetry && html`
291
+ <${MessageActions}
292
+ onRetry=${handleRetry}
293
+ isLoading=${isLoading}
294
+ position="right"
295
+ showEdit=${false}
296
+ />
297
+ `}
146
298
  ${debugMode && html`<${DebugPayload} msg=${msg} show=${showPayload} onToggle=${() => setShowPayload(!showPayload)} />`}
147
299
  </div>
148
300
  `;
@@ -13,6 +13,8 @@ export function MessageList({
13
13
  hasMoreMessages,
14
14
  loadingMoreMessages,
15
15
  onLoadMore,
16
+ onEditMessage,
17
+ onRetryMessage,
16
18
  debugMode,
17
19
  markdownParser,
18
20
  emptyStateTitle,
@@ -83,12 +85,16 @@ export function MessageList({
83
85
  </div>
84
86
  `}
85
87
 
86
- ${messages.map(msg => html`
87
- <${Message}
88
- key=${msg.id}
89
- msg=${msg}
88
+ ${messages.map((msg, index) => html`
89
+ <${Message}
90
+ key=${msg.id}
91
+ msg=${msg}
92
+ messageIndex=${index}
90
93
  debugMode=${debugMode}
91
94
  markdownParser=${markdownParser}
95
+ onEdit=${onEditMessage}
96
+ onRetry=${onRetryMessage}
97
+ isLoading=${isLoading}
92
98
  />
93
99
  `)}
94
100
 
@@ -0,0 +1,183 @@
1
+ /**
2
+ * TaskList component - displays and manages the user's task list
3
+ */
4
+
5
+ import { html } from 'htm/preact';
6
+ import { useState, useCallback } from 'preact/hooks';
7
+
8
+ // State icons for tasks
9
+ const STATE_ICONS = {
10
+ not_started: '○',
11
+ in_progress: '◐',
12
+ complete: '●',
13
+ cancelled: '⊘',
14
+ };
15
+
16
+ const STATE_LABELS = {
17
+ not_started: 'Not Started',
18
+ in_progress: 'In Progress',
19
+ complete: 'Complete',
20
+ cancelled: 'Cancelled',
21
+ };
22
+
23
+ function TaskItem({ task, onUpdate, onRemove, depth = 0 }) {
24
+ const [isEditing, setIsEditing] = useState(false);
25
+ const [editName, setEditName] = useState(task.name);
26
+
27
+ const handleStateToggle = useCallback(() => {
28
+ // Cycle through states: not_started -> in_progress -> complete
29
+ const nextState = {
30
+ not_started: 'in_progress',
31
+ in_progress: 'complete',
32
+ complete: 'not_started',
33
+ cancelled: 'not_started',
34
+ };
35
+ onUpdate(task.id, { state: nextState[task.state] || 'not_started' });
36
+ }, [task, onUpdate]);
37
+
38
+ const handleSaveEdit = useCallback(() => {
39
+ if (editName.trim() && editName !== task.name) {
40
+ onUpdate(task.id, { name: editName.trim() });
41
+ }
42
+ setIsEditing(false);
43
+ }, [task, editName, onUpdate]);
44
+
45
+ const handleKeyDown = useCallback((e) => {
46
+ if (e.key === 'Enter') handleSaveEdit();
47
+ if (e.key === 'Escape') {
48
+ setEditName(task.name);
49
+ setIsEditing(false);
50
+ }
51
+ }, [handleSaveEdit, task.name]);
52
+
53
+ const stateClass = `cw-task-state-${task.state.replace('_', '-')}`;
54
+
55
+ return html`
56
+ <div class="cw-task-item ${stateClass}" style=${{ paddingLeft: `${depth * 16 + 8}px` }}>
57
+ <button
58
+ class="cw-task-state-btn"
59
+ onClick=${handleStateToggle}
60
+ title=${STATE_LABELS[task.state]}
61
+ >
62
+ ${STATE_ICONS[task.state]}
63
+ </button>
64
+
65
+ ${isEditing ? html`
66
+ <input
67
+ type="text"
68
+ class="cw-task-edit-input"
69
+ value=${editName}
70
+ onInput=${(e) => setEditName(e.target.value)}
71
+ onBlur=${handleSaveEdit}
72
+ onKeyDown=${handleKeyDown}
73
+ autoFocus
74
+ />
75
+ ` : html`
76
+ <span
77
+ class="cw-task-name"
78
+ onClick=${() => setIsEditing(true)}
79
+ title="Click to edit"
80
+ >
81
+ ${task.name}
82
+ </span>
83
+ `}
84
+
85
+ <button
86
+ class="cw-task-remove-btn"
87
+ onClick=${() => onRemove(task.id)}
88
+ title="Remove task"
89
+ >
90
+ ×
91
+ </button>
92
+ </div>
93
+ `;
94
+ }
95
+
96
+ export function TaskList({
97
+ tasks,
98
+ progress,
99
+ isLoading,
100
+ error,
101
+ onUpdate,
102
+ onRemove,
103
+ onClear,
104
+ onRefresh
105
+ }) {
106
+ // Build tree structure from flat tasks
107
+ const buildTree = useCallback((tasks) => {
108
+ const taskMap = new Map();
109
+ const roots = [];
110
+
111
+ // First pass: create map
112
+ tasks.forEach(task => {
113
+ taskMap.set(task.id, { ...task, children: [] });
114
+ });
115
+
116
+ // Second pass: build tree
117
+ tasks.forEach(task => {
118
+ const node = taskMap.get(task.id);
119
+ if (task.parent_id && taskMap.has(task.parent_id)) {
120
+ taskMap.get(task.parent_id).children.push(node);
121
+ } else {
122
+ roots.push(node);
123
+ }
124
+ });
125
+
126
+ return roots;
127
+ }, []);
128
+
129
+ const renderTask = useCallback((task, depth = 0) => {
130
+ return html`
131
+ <${TaskItem}
132
+ key=${task.id}
133
+ task=${task}
134
+ depth=${depth}
135
+ onUpdate=${onUpdate}
136
+ onRemove=${onRemove}
137
+ />
138
+ ${task.children?.map(child => renderTask(child, depth + 1))}
139
+ `;
140
+ }, [onUpdate, onRemove]);
141
+
142
+ const taskTree = buildTree(tasks);
143
+
144
+ if (isLoading && tasks.length === 0) {
145
+ return html`<div class="cw-tasks-loading">Loading tasks...</div>`;
146
+ }
147
+
148
+ return html`
149
+ <div class="cw-tasks-container">
150
+ <div class="cw-tasks-header">
151
+ <div class="cw-tasks-progress">
152
+ <span class="cw-tasks-progress-text">
153
+ ${progress.completed}/${progress.total} complete
154
+ </span>
155
+ <div class="cw-tasks-progress-bar">
156
+ <div
157
+ class="cw-tasks-progress-fill"
158
+ style=${{ width: `${progress.percent_complete}%` }}
159
+ />
160
+ </div>
161
+ </div>
162
+ <div class="cw-tasks-actions">
163
+ <button class="cw-tasks-action-btn" onClick=${onRefresh} title="Refresh">↻</button>
164
+ ${tasks.length > 0 && html`
165
+ <button class="cw-tasks-action-btn" onClick=${onClear} title="Clear all">🗑</button>
166
+ `}
167
+ </div>
168
+ </div>
169
+
170
+ ${error && html`<div class="cw-tasks-error">${error}</div>`}
171
+
172
+ <div class="cw-tasks-list">
173
+ ${taskTree.length === 0 ? html`
174
+ <div class="cw-tasks-empty">
175
+ <p>No tasks yet</p>
176
+ <p class="cw-tasks-empty-hint">Tasks will appear here when the agent creates them</p>
177
+ </div>
178
+ ` : taskTree.map(task => renderTask(task))}
179
+ </div>
180
+ </div>
181
+ `;
182
+ }
183
+
@@ -224,7 +224,7 @@ export function useChat(config, api, storage) {
224
224
  options = optionsOrFiles || {};
225
225
  }
226
226
 
227
- const { model, onAssistantMessage } = options;
227
+ const { model, onAssistantMessage, supersedeFromMessageIndex } = options;
228
228
 
229
229
  setIsLoading(true);
230
230
  setError(null);
@@ -289,6 +289,8 @@ export function useChat(config, api, storage) {
289
289
  messages: [{ role: 'user', content: content.trim() }],
290
290
  metadata: { ...config.metadata, journeyType: config.defaultJourneyType },
291
291
  ...(model && { model }),
292
+ // Edit/retry support: tell backend to mark old runs as superseded
293
+ ...(supersedeFromMessageIndex !== undefined && { supersedeFromMessageIndex }),
292
294
  });
293
295
 
294
296
  fetchOptions = api.getFetchOptions({
@@ -487,6 +489,69 @@ export function useChat(config, api, storage) {
487
489
  }
488
490
  }, [config, api, conversationId, messagesOffset, loadingMoreMessages, hasMoreMessages]);
489
491
 
492
+ // Edit a message and resend from that point
493
+ // This truncates the conversation to the edited message and resends
494
+ // The backend will mark old runs as superseded so conversation history stays clean
495
+ const editMessage = useCallback(async (messageIndex, newContent, options = {}) => {
496
+ if (isLoading) return;
497
+
498
+ // Find the message to edit
499
+ const messageToEdit = messages[messageIndex];
500
+ if (!messageToEdit || messageToEdit.role !== 'user') return;
501
+
502
+ // Truncate messages to just before this message
503
+ const truncatedMessages = messages.slice(0, messageIndex);
504
+ setMessages(truncatedMessages);
505
+
506
+ // Send the edited message with supersede flag
507
+ // This tells the backend to mark old runs from this point as superseded
508
+ await sendMessage(newContent, {
509
+ ...options,
510
+ supersedeFromMessageIndex: messageIndex,
511
+ });
512
+ }, [messages, isLoading, sendMessage]);
513
+
514
+ // Retry from a specific message (resend the same content)
515
+ // For user messages: retry that message
516
+ // For assistant messages: find the previous user message and retry from there
517
+ // The backend will mark old runs as superseded so conversation history stays clean
518
+ const retryMessage = useCallback(async (messageIndex, options = {}) => {
519
+ if (isLoading) return;
520
+
521
+ const messageAtIndex = messages[messageIndex];
522
+ if (!messageAtIndex) return;
523
+
524
+ let userMessageIndex = messageIndex;
525
+ let userMessage = messageAtIndex;
526
+
527
+ // If this is an assistant message, find the previous user message
528
+ if (messageAtIndex.role === 'assistant') {
529
+ for (let i = messageIndex - 1; i >= 0; i--) {
530
+ if (messages[i].role === 'user') {
531
+ userMessageIndex = i;
532
+ userMessage = messages[i];
533
+ break;
534
+ }
535
+ }
536
+ // If no user message found, can't retry
537
+ if (userMessage.role !== 'user') return;
538
+ } else if (messageAtIndex.role !== 'user') {
539
+ // Only user and assistant messages can be retried
540
+ return;
541
+ }
542
+
543
+ // Truncate messages to just before the user message
544
+ const truncatedMessages = messages.slice(0, userMessageIndex);
545
+ setMessages(truncatedMessages);
546
+
547
+ // Resend the same message with supersede flag
548
+ // This tells the backend to mark old runs from this point as superseded
549
+ await sendMessage(userMessage.content, {
550
+ ...options,
551
+ supersedeFromMessageIndex: userMessageIndex,
552
+ });
553
+ }, [messages, isLoading, sendMessage]);
554
+
490
555
  // Cleanup on unmount
491
556
  useEffect(() => {
492
557
  return () => {
@@ -509,6 +574,8 @@ export function useChat(config, api, storage) {
509
574
  loadConversation,
510
575
  loadMoreMessages,
511
576
  setConversationId,
577
+ editMessage,
578
+ retryMessage,
512
579
  };
513
580
  }
514
581