@makemore/agent-frontend 2.3.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/README.md +10 -1
- package/dist/chat-widget.css +442 -1
- package/dist/chat-widget.js +368 -171
- package/package.json +1 -1
- package/src/components/ChatWidget.js +73 -27
- package/src/components/Message.js +156 -4
- package/src/components/MessageList.js +10 -4
- package/src/components/TaskList.js +183 -0
- package/src/hooks/useChat.js +97 -22
- package/src/hooks/useTasks.js +164 -0
- package/src/utils/api.js +25 -1
- package/src/utils/config.js +6 -0
- package/src/utils/helpers.js +69 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@makemore/agent-frontend",
|
|
3
|
-
"version": "2.
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
272
|
+
${chat.error && html`<div class="cw-error-bar">${chat.error}</div>`}
|
|
239
273
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
|
273
|
+
<div class="${rowClasses} ${hasActions ? 'cw-message-row-with-actions' : ''}">
|
|
144
274
|
${renderAttachments()}
|
|
145
|
-
|
|
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
|
+
|