@swarmclawai/swarmclaw 0.6.0 → 0.6.2

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.
Files changed (109) hide show
  1. package/README.md +15 -2
  2. package/bin/server-cmd.js +1 -0
  3. package/package.json +2 -1
  4. package/src/app/api/canvas/[sessionId]/route.ts +31 -0
  5. package/src/app/api/chatrooms/[id]/chat/route.ts +10 -136
  6. package/src/app/api/connectors/[id]/route.ts +1 -0
  7. package/src/app/api/connectors/route.ts +2 -1
  8. package/src/app/api/files/open/route.ts +43 -0
  9. package/src/app/api/search/route.ts +9 -7
  10. package/src/app/api/sessions/[id]/messages/route.ts +70 -2
  11. package/src/app/api/sessions/[id]/route.ts +4 -0
  12. package/src/app/api/tasks/metrics/route.ts +101 -0
  13. package/src/app/api/tasks/route.ts +17 -2
  14. package/src/app/api/tts/route.ts +3 -2
  15. package/src/app/api/tts/stream/route.ts +3 -2
  16. package/src/app/api/uploads/[filename]/route.ts +19 -34
  17. package/src/app/api/uploads/route.ts +94 -0
  18. package/src/app/globals.css +5 -0
  19. package/src/cli/index.js +16 -1
  20. package/src/cli/spec.js +26 -0
  21. package/src/components/agents/agent-card.tsx +3 -3
  22. package/src/components/agents/agent-chat-list.tsx +29 -6
  23. package/src/components/agents/agent-sheet.tsx +66 -4
  24. package/src/components/agents/inspector-panel.tsx +81 -6
  25. package/src/components/agents/openclaw-skills-panel.tsx +32 -3
  26. package/src/components/agents/personality-builder.tsx +42 -14
  27. package/src/components/agents/soul-library-picker.tsx +89 -0
  28. package/src/components/canvas/canvas-panel.tsx +96 -0
  29. package/src/components/chat/activity-moment.tsx +8 -4
  30. package/src/components/chat/chat-area.tsx +46 -22
  31. package/src/components/chat/chat-header.tsx +455 -286
  32. package/src/components/chat/chat-preview-panel.tsx +1 -2
  33. package/src/components/chat/delegation-banner.tsx +371 -0
  34. package/src/components/chat/file-path-chip.tsx +23 -2
  35. package/src/components/chat/heartbeat-history-panel.tsx +269 -0
  36. package/src/components/chat/message-bubble.tsx +315 -25
  37. package/src/components/chat/message-list.tsx +180 -7
  38. package/src/components/chat/streaming-bubble.tsx +68 -1
  39. package/src/components/chat/tool-call-bubble.tsx +45 -3
  40. package/src/components/chat/transfer-agent-picker.tsx +1 -1
  41. package/src/components/chatrooms/chatroom-list.tsx +8 -1
  42. package/src/components/chatrooms/chatroom-message.tsx +8 -3
  43. package/src/components/chatrooms/chatroom-view.tsx +3 -3
  44. package/src/components/connectors/connector-list.tsx +168 -90
  45. package/src/components/connectors/connector-sheet.tsx +68 -16
  46. package/src/components/home/home-view.tsx +1 -1
  47. package/src/components/input/chat-input.tsx +28 -2
  48. package/src/components/layout/app-layout.tsx +19 -2
  49. package/src/components/projects/project-detail.tsx +1 -1
  50. package/src/components/schedules/schedule-sheet.tsx +260 -127
  51. package/src/components/settings/gateway-disconnect-overlay.tsx +80 -0
  52. package/src/components/shared/agent-switch-dialog.tsx +1 -1
  53. package/src/components/shared/chatroom-picker-list.tsx +61 -0
  54. package/src/components/shared/connector-platform-icon.tsx +51 -4
  55. package/src/components/shared/icon-button.tsx +16 -2
  56. package/src/components/shared/keyboard-shortcuts-dialog.tsx +1 -1
  57. package/src/components/shared/search-dialog.tsx +17 -10
  58. package/src/components/shared/settings/section-embedding.tsx +48 -13
  59. package/src/components/shared/settings/section-orchestrator.tsx +46 -15
  60. package/src/components/shared/settings/section-storage.tsx +206 -0
  61. package/src/components/shared/settings/section-user-preferences.tsx +18 -0
  62. package/src/components/shared/settings/section-voice.tsx +42 -21
  63. package/src/components/shared/settings/section-web-search.tsx +30 -6
  64. package/src/components/shared/settings/settings-page.tsx +3 -1
  65. package/src/components/shared/settings/storage-browser.tsx +259 -0
  66. package/src/components/tasks/task-card.tsx +14 -1
  67. package/src/components/tasks/task-sheet.tsx +328 -3
  68. package/src/components/usage/metrics-dashboard.tsx +90 -6
  69. package/src/hooks/use-continuous-speech.ts +10 -4
  70. package/src/hooks/use-voice-conversation.ts +53 -10
  71. package/src/hooks/use-ws.ts +4 -2
  72. package/src/lib/providers/anthropic.ts +13 -7
  73. package/src/lib/providers/index.ts +1 -0
  74. package/src/lib/providers/openai.ts +13 -7
  75. package/src/lib/server/chat-execution.ts +51 -11
  76. package/src/lib/server/chatroom-helpers.ts +146 -0
  77. package/src/lib/server/connectors/manager.ts +218 -7
  78. package/src/lib/server/heartbeat-service.ts +8 -1
  79. package/src/lib/server/main-agent-loop.ts +1 -1
  80. package/src/lib/server/memory-consolidation.ts +15 -2
  81. package/src/lib/server/memory-db.ts +134 -6
  82. package/src/lib/server/mime.ts +51 -0
  83. package/src/lib/server/openclaw-gateway.ts +2 -2
  84. package/src/lib/server/orchestrator-lg.ts +2 -0
  85. package/src/lib/server/orchestrator.ts +5 -2
  86. package/src/lib/server/playwright-proxy.mjs +2 -3
  87. package/src/lib/server/prompt-runtime-context.ts +53 -0
  88. package/src/lib/server/queue.ts +52 -7
  89. package/src/lib/server/session-tools/canvas.ts +67 -0
  90. package/src/lib/server/session-tools/connector.ts +83 -9
  91. package/src/lib/server/session-tools/crud.ts +21 -0
  92. package/src/lib/server/session-tools/delegate.ts +68 -4
  93. package/src/lib/server/session-tools/git.ts +71 -0
  94. package/src/lib/server/session-tools/http.ts +57 -0
  95. package/src/lib/server/session-tools/index.ts +8 -0
  96. package/src/lib/server/session-tools/memory.ts +1 -0
  97. package/src/lib/server/session-tools/search-providers.ts +16 -8
  98. package/src/lib/server/session-tools/subagent.ts +106 -0
  99. package/src/lib/server/session-tools/web.ts +115 -4
  100. package/src/lib/server/stream-agent-chat.ts +32 -10
  101. package/src/lib/server/task-mention.ts +41 -0
  102. package/src/lib/sessions.ts +10 -0
  103. package/src/lib/soul-library.ts +103 -0
  104. package/src/lib/task-dedupe.ts +26 -0
  105. package/src/lib/tool-definitions.ts +2 -0
  106. package/src/lib/tts.ts +2 -2
  107. package/src/stores/use-app-store.ts +5 -1
  108. package/src/stores/use-chat-store.ts +65 -2
  109. package/src/types/index.ts +32 -2
@@ -1,6 +1,8 @@
1
1
  'use client'
2
2
 
3
3
  import { useEffect, useState } from 'react'
4
+ import ReactMarkdown from 'react-markdown'
5
+ import remarkGfm from 'remark-gfm'
4
6
  import { useAppStore } from '@/stores/use-app-store'
5
7
  import { createTask, updateTask, archiveTask, unarchiveTask } from '@/lib/tasks'
6
8
  import { BottomSheet } from '@/components/shared/bottom-sheet'
@@ -10,6 +12,7 @@ import { SheetFooter } from '@/components/shared/sheet-footer'
10
12
  import { inputClass } from '@/components/shared/form-styles'
11
13
  import type { BoardTask, TaskComment } from '@/types'
12
14
  import { SectionLabel } from '@/components/shared/section-label'
15
+ import { AgentAvatar } from '@/components/agents/agent-avatar'
13
16
 
14
17
  function fmtTime(ts: number) {
15
18
  const d = new Date(ts)
@@ -33,6 +36,9 @@ export function TaskSheet() {
33
36
  const loadProjects = useAppStore((s) => s.loadProjects)
34
37
  const activeProjectFilter = useAppStore((s) => s.activeProjectFilter)
35
38
 
39
+ const viewOnly = useAppStore((s) => s.taskSheetViewOnly)
40
+ const setViewOnly = useAppStore((s) => s.setTaskSheetViewOnly)
41
+
36
42
  const appSettings = useAppStore((s) => s.appSettings)
37
43
  const loadSettings = useAppStore((s) => s.loadSettings)
38
44
 
@@ -50,6 +56,7 @@ export function TaskSheet() {
50
56
  const [blockedBy, setBlockedBy] = useState<string[]>([])
51
57
  const [dueAt, setDueAt] = useState<string>('')
52
58
  const [customFields, setCustomFields] = useState<Record<string, string | number | boolean>>({})
59
+ const [priority, setPriority] = useState<'low' | 'medium' | 'high' | 'critical' | ''>('')
53
60
 
54
61
  const editing = editingId ? tasks[editingId] : null
55
62
  const agentList = Object.values(agents).sort((a, b) => a.name.localeCompare(b.name))
@@ -71,6 +78,7 @@ export function TaskSheet() {
71
78
  setBlockedBy(editing.blockedBy || [])
72
79
  setDueAt(editing.dueAt ? new Date(editing.dueAt).toISOString().slice(0, 10) : '')
73
80
  setCustomFields(editing.customFields || {})
81
+ setPriority(editing.priority || '')
74
82
  } else {
75
83
  setTitle('')
76
84
  setDescription('')
@@ -83,6 +91,7 @@ export function TaskSheet() {
83
91
  setBlockedBy([])
84
92
  setDueAt('')
85
93
  setCustomFields({})
94
+ setPriority('')
86
95
  }
87
96
  }
88
97
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -108,6 +117,7 @@ export function TaskSheet() {
108
117
  cwd: cwd || undefined, file: file || undefined,
109
118
  tags, blockedBy, dueAt: dueAt ? new Date(dueAt).getTime() : null,
110
119
  customFields: Object.keys(customFields).length > 0 ? customFields : undefined,
120
+ priority: priority || undefined,
111
121
  } as Partial<BoardTask> & { title: string; description: string; agentId: string }
112
122
  if (editing) {
113
123
  await updateTask(editing.id, payload)
@@ -130,7 +140,9 @@ export function TaskSheet() {
130
140
  })
131
141
  const data = await res.json()
132
142
  if (data.url) setImages((prev) => [...prev, data.url])
133
- } catch { /* ignore */ }
143
+ } catch (err: unknown) {
144
+ console.error('Image upload failed:', err instanceof Error ? err.message : String(err))
145
+ }
134
146
  setUploading(false)
135
147
  e.target.value = ''
136
148
  }
@@ -173,9 +185,296 @@ export function TaskSheet() {
173
185
  setCommentText('')
174
186
  }
175
187
 
188
+ const PRIORITY_STYLES: Record<string, string> = {
189
+ low: 'bg-sky-500/10 border-sky-500/20 text-sky-400',
190
+ medium: 'bg-amber-500/10 border-amber-500/20 text-amber-400',
191
+ high: 'bg-orange-500/10 border-orange-500/20 text-orange-400',
192
+ critical: 'bg-red-500/10 border-red-500/20 text-red-400',
193
+ }
194
+ const STATUS_STYLES: Record<string, string> = {
195
+ backlog: 'bg-white/[0.06] text-text-3',
196
+ queued: 'bg-amber-500/10 text-amber-400',
197
+ 'in-progress': 'bg-sky-500/10 text-sky-400',
198
+ completed: 'bg-emerald-500/10 text-emerald-400',
199
+ failed: 'bg-red-500/10 text-red-400',
200
+ archived: 'bg-white/[0.04] text-text-3/60',
201
+ }
202
+
203
+ const taskAgent = editing ? agents[editing.agentId] : null
204
+ const taskProject = editing?.projectId ? projects[editing.projectId] : null
205
+
206
+ /* ───── View-only mode ───── */
207
+ if (viewOnly && editing) {
208
+ return (
209
+ <BottomSheet open={open} onClose={onClose}>
210
+ {/* Header: title + badges + timestamps */}
211
+ <div className="mb-8">
212
+ <h2 className="font-display text-[28px] font-700 tracking-[-0.03em] mb-3">
213
+ {editing.title}
214
+ </h2>
215
+ <div className="flex flex-wrap items-center gap-2 mb-3">
216
+ <span className={`px-2.5 py-1 rounded-[8px] text-[12px] font-600 border border-transparent ${STATUS_STYLES[editing.status] || 'bg-white/[0.06] text-text-3'}`}>
217
+ {editing.status}
218
+ </span>
219
+ {editing.priority && (
220
+ <span className={`px-2.5 py-1 rounded-[8px] text-[12px] font-600 border ${PRIORITY_STYLES[editing.priority] || ''}`}>
221
+ {editing.priority}
222
+ </span>
223
+ )}
224
+ </div>
225
+ <div className="flex flex-wrap gap-x-4 gap-y-1 text-[12px] text-text-3">
226
+ <span>Created {fmtTime(editing.createdAt)}</span>
227
+ {editing.startedAt && <span>Started {fmtTime(editing.startedAt)}</span>}
228
+ {editing.completedAt && <span>Completed {fmtTime(editing.completedAt)}</span>}
229
+ </div>
230
+ </div>
231
+
232
+ {/* Description */}
233
+ {editing.description && (
234
+ <div className="mb-8">
235
+ <SectionLabel>Description</SectionLabel>
236
+ <div className="msg-content text-[14px] leading-[1.7] text-text-2 break-words p-4 rounded-[14px] border border-white/[0.06] bg-surface">
237
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{editing.description}</ReactMarkdown>
238
+ </div>
239
+ </div>
240
+ )}
241
+
242
+ {/* Agent */}
243
+ {taskAgent && (
244
+ <div className="mb-8">
245
+ <SectionLabel>Agent</SectionLabel>
246
+ <div className="flex items-center gap-2.5 px-4 py-3 rounded-[14px] border border-white/[0.06] bg-surface">
247
+ <AgentAvatar seed={taskAgent.avatarSeed || null} name={taskAgent.name} size={24} />
248
+ <span className="text-[14px] font-600 text-text">{taskAgent.name}</span>
249
+ </div>
250
+ </div>
251
+ )}
252
+
253
+ {/* Project */}
254
+ {taskProject && (
255
+ <div className="mb-8">
256
+ <SectionLabel>Project</SectionLabel>
257
+ <span className="inline-flex items-center gap-2 px-3 py-2 rounded-[10px] border border-white/[0.06] bg-surface text-[13px] font-600 text-text-2">
258
+ <span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: taskProject.color || '#6366F1' }} />
259
+ {taskProject.name}
260
+ </span>
261
+ </div>
262
+ )}
263
+
264
+ {/* Directory / File */}
265
+ {(editing.cwd || editing.file) && (
266
+ <div className="mb-8">
267
+ <SectionLabel>{editing.file ? 'File' : 'Directory'}</SectionLabel>
268
+ <code className="block px-4 py-3 rounded-[14px] border border-white/[0.06] bg-surface text-[13px] text-text-2 font-mono break-all">
269
+ {editing.file || editing.cwd}
270
+ </code>
271
+ </div>
272
+ )}
273
+
274
+ {/* Tags */}
275
+ {editing.tags && editing.tags.length > 0 && (
276
+ <div className="mb-8">
277
+ <SectionLabel>Tags</SectionLabel>
278
+ <div className="flex flex-wrap gap-1.5">
279
+ {editing.tags.map((tag) => (
280
+ <span key={tag} className="px-2.5 py-1 rounded-[8px] bg-indigo-500/10 text-indigo-400 text-[12px] font-600">
281
+ {tag}
282
+ </span>
283
+ ))}
284
+ </div>
285
+ </div>
286
+ )}
287
+
288
+ {/* Blocked By */}
289
+ {editing.blockedBy && editing.blockedBy.length > 0 && (
290
+ <div className="mb-8">
291
+ <SectionLabel>Blocked By</SectionLabel>
292
+ <div className="flex flex-wrap gap-1.5">
293
+ {editing.blockedBy.map((bid) => {
294
+ const bt = tasks[bid]
295
+ return (
296
+ <span key={bid} className="px-2.5 py-1 rounded-[8px] bg-white/[0.04] text-text-3 text-[12px] font-600">
297
+ {bt ? bt.title : bid}
298
+ </span>
299
+ )
300
+ })}
301
+ </div>
302
+ </div>
303
+ )}
304
+
305
+ {/* Blocks */}
306
+ {editing.blocks && editing.blocks.length > 0 && (
307
+ <div className="mb-8">
308
+ <SectionLabel>Blocks</SectionLabel>
309
+ <div className="flex flex-wrap gap-1.5">
310
+ {editing.blocks.map((bid) => {
311
+ const bt = tasks[bid]
312
+ return bt ? (
313
+ <span key={bid} className="px-2.5 py-1 rounded-[8px] bg-white/[0.04] text-text-3 text-[12px] font-600">{bt.title}</span>
314
+ ) : null
315
+ })}
316
+ </div>
317
+ </div>
318
+ )}
319
+
320
+ {/* Due Date */}
321
+ {editing.dueAt && (
322
+ <div className="mb-8">
323
+ <SectionLabel>Due Date</SectionLabel>
324
+ <span className="text-[14px] text-text-2">{new Date(editing.dueAt).toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })}</span>
325
+ </div>
326
+ )}
327
+
328
+ {/* Custom Fields */}
329
+ {editing.customFields && Object.keys(editing.customFields).length > 0 && (
330
+ <div className="mb-8">
331
+ <SectionLabel>Custom Fields</SectionLabel>
332
+ <div className="space-y-2">
333
+ {Object.entries(editing.customFields).map(([key, val]) => {
334
+ const def = appSettings.taskCustomFieldDefs?.find((d) => d.key === key)
335
+ return (
336
+ <div key={key} className="flex items-baseline gap-2">
337
+ <span className="text-[12px] font-600 text-text-3">{def?.label || key}:</span>
338
+ <span className="text-[13px] text-text-2">{String(val)}</span>
339
+ </div>
340
+ )
341
+ })}
342
+ </div>
343
+ </div>
344
+ )}
345
+
346
+ {/* Images (thumbnails only, no remove/upload) */}
347
+ {editing.images && editing.images.length > 0 && (
348
+ <div className="mb-8">
349
+ <SectionLabel>Images</SectionLabel>
350
+ <div className="flex gap-2 flex-wrap">
351
+ {editing.images.map((url, i) => (
352
+ // eslint-disable-next-line @next/next/no-img-element
353
+ <img key={i} src={url} alt="" className="w-20 h-20 rounded-[10px] object-cover border border-white/[0.08]" />
354
+ ))}
355
+ </div>
356
+ </div>
357
+ )}
358
+
359
+ {/* Result */}
360
+ {editing.result && (
361
+ <div className="mb-8">
362
+ <SectionLabel>Result</SectionLabel>
363
+ <div className="p-4 rounded-[14px] border border-white/[0.06] bg-surface text-[13px] text-text-2 whitespace-pre-wrap max-h-[200px] overflow-y-auto">
364
+ {editing.result}
365
+ </div>
366
+ </div>
367
+ )}
368
+
369
+ {/* CLI Sessions */}
370
+ {(editing.claudeResumeId || editing.codexResumeId || editing.opencodeResumeId || editing.cliResumeId) && (
371
+ <div className="mb-8">
372
+ <SectionLabel>CLI Sessions</SectionLabel>
373
+ <div className="flex flex-wrap gap-2">
374
+ {editing.claudeResumeId && (
375
+ <div className="flex items-center gap-2 px-3 py-2 rounded-[10px] border border-white/[0.06] bg-surface">
376
+ <span className="text-[11px] font-600 text-amber-400">Claude</span>
377
+ <code className="text-[11px] text-text-3 font-mono">{editing.claudeResumeId}</code>
378
+ </div>
379
+ )}
380
+ {editing.codexResumeId && (
381
+ <div className="flex items-center gap-2 px-3 py-2 rounded-[10px] border border-white/[0.06] bg-surface">
382
+ <span className="text-[11px] font-600 text-emerald-400">Codex</span>
383
+ <code className="text-[11px] text-text-3 font-mono">{editing.codexResumeId}</code>
384
+ </div>
385
+ )}
386
+ {editing.opencodeResumeId && (
387
+ <div className="flex items-center gap-2 px-3 py-2 rounded-[10px] border border-white/[0.06] bg-surface">
388
+ <span className="text-[11px] font-600 text-sky-400">OpenCode</span>
389
+ <code className="text-[11px] text-text-3 font-mono">{editing.opencodeResumeId}</code>
390
+ </div>
391
+ )}
392
+ {!(editing.claudeResumeId || editing.codexResumeId || editing.opencodeResumeId) && editing.cliResumeId && (
393
+ <div className="flex items-center gap-2 px-3 py-2 rounded-[10px] border border-white/[0.06] bg-surface">
394
+ <span className="text-[11px] font-600 text-text-2">{editing.cliProvider || 'CLI'}</span>
395
+ <code className="text-[11px] text-text-3 font-mono">{editing.cliResumeId}</code>
396
+ </div>
397
+ )}
398
+ </div>
399
+ </div>
400
+ )}
401
+
402
+ {/* Error */}
403
+ {editing.error && (
404
+ <div className="mb-8">
405
+ <label className="block font-display text-[12px] font-600 text-red-400 uppercase tracking-[0.08em] mb-3">Error</label>
406
+ <div className="p-4 rounded-[14px] border border-red-500/10 bg-red-500/[0.03] text-[13px] text-red-400/80 whitespace-pre-wrap">
407
+ {editing.error}
408
+ </div>
409
+ </div>
410
+ )}
411
+
412
+ {/* Comments (with input — adding comments from view mode is useful) */}
413
+ <div className="mb-8">
414
+ <SectionLabel>Comments {editing.comments?.length ? `(${editing.comments.length})` : ''}</SectionLabel>
415
+
416
+ {editing.comments && editing.comments.length > 0 && (
417
+ <div className="space-y-3 mb-4 max-h-[300px] overflow-y-auto">
418
+ {editing.comments.map((c) => (
419
+ <div key={c.id} className="p-3.5 rounded-[12px] border border-white/[0.06] bg-surface">
420
+ <div className="flex items-center gap-2 mb-1.5">
421
+ <span className={`text-[12px] font-600 ${c.agentId ? 'text-accent-bright' : 'text-text-2'}`}>
422
+ {c.author}
423
+ </span>
424
+ <span className="text-[10px] text-text-3/50 font-mono">{fmtTime(c.createdAt)}</span>
425
+ </div>
426
+ <p className="text-[13px] text-text-2 leading-[1.5] whitespace-pre-wrap">{c.text}</p>
427
+ </div>
428
+ ))}
429
+ </div>
430
+ )}
431
+
432
+ <div className="flex gap-2">
433
+ <input
434
+ type="text"
435
+ value={commentText}
436
+ onChange={(e) => setCommentText(e.target.value)}
437
+ placeholder="Add a comment..."
438
+ className={`${inputClass} flex-1`}
439
+ style={{ fontFamily: 'inherit' }}
440
+ onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleAddComment() } }}
441
+ />
442
+ <button
443
+ onClick={handleAddComment}
444
+ disabled={!commentText.trim()}
445
+ className="px-4 py-3 rounded-[14px] border-none bg-accent-soft text-accent-bright text-[13px] font-600 cursor-pointer disabled:opacity-30 hover:brightness-110 transition-all shrink-0"
446
+ style={{ fontFamily: 'inherit' }}
447
+ >
448
+ Post
449
+ </button>
450
+ </div>
451
+ </div>
452
+
453
+ {/* Footer: Edit + Close */}
454
+ <div className="flex gap-3 pt-2 border-t border-white/[0.04]">
455
+ <button
456
+ onClick={onClose}
457
+ className="flex-1 py-3.5 rounded-[14px] border border-white/[0.08] bg-transparent text-text-2 text-[15px] font-600 cursor-pointer hover:bg-surface-2 transition-all"
458
+ style={{ fontFamily: 'inherit' }}
459
+ >
460
+ Close
461
+ </button>
462
+ <button
463
+ onClick={() => setViewOnly(false)}
464
+ className="flex-1 py-3.5 rounded-[14px] border-none bg-accent-bright text-white text-[15px] font-600 cursor-pointer active:scale-[0.97] transition-all shadow-[0_4px_20px_rgba(99,102,241,0.25)] hover:brightness-110"
465
+ style={{ fontFamily: 'inherit' }}
466
+ >
467
+ Edit
468
+ </button>
469
+ </div>
470
+ </BottomSheet>
471
+ )
472
+ }
473
+
474
+ /* ───── Edit / Create mode ───── */
176
475
  return (
177
476
  <BottomSheet open={open} onClose={onClose}>
178
- <div className="mb-10">
477
+ <div className="mb-8">
179
478
  <h2 className="font-display text-[28px] font-700 tracking-[-0.03em] mb-2">
180
479
  {editing ? 'Edit Task' : 'New Task'}
181
480
  </h2>
@@ -201,13 +500,38 @@ export function TaskSheet() {
201
500
  <textarea
202
501
  value={description}
203
502
  onChange={(e) => setDescription(e.target.value)}
204
- placeholder="Detailed task instructions for the agent..."
503
+ placeholder="Detailed task instructions... Use @AgentName to auto-assign"
205
504
  rows={4}
206
505
  className={`${inputClass} resize-y min-h-[100px]`}
207
506
  style={{ fontFamily: 'inherit' }}
208
507
  />
209
508
  </div>
210
509
 
510
+ {/* Priority */}
511
+ <div className="mb-8">
512
+ <SectionLabel>Priority <span className="normal-case tracking-normal font-normal text-text-3">(optional)</span></SectionLabel>
513
+ <div className="flex flex-wrap gap-2">
514
+ {([['', 'None', 'bg-surface border-white/[0.06] text-text-2'],
515
+ ['low', 'Low', 'bg-sky-500/10 border-sky-500/20 text-sky-400'],
516
+ ['medium', 'Medium', 'bg-amber-500/10 border-amber-500/20 text-amber-400'],
517
+ ['high', 'High', 'bg-orange-500/10 border-orange-500/20 text-orange-400'],
518
+ ['critical', 'Critical', 'bg-red-500/10 border-red-500/20 text-red-400'],
519
+ ] as const).map(([val, label, cls]) => (
520
+ <button
521
+ key={val}
522
+ onClick={() => setPriority(val as typeof priority)}
523
+ className={`px-4 py-3 rounded-[12px] text-[14px] font-600 cursor-pointer transition-all border
524
+ ${priority === val
525
+ ? `${cls} ring-1 ring-current`
526
+ : 'bg-surface border-white/[0.06] text-text-2 hover:bg-surface-2'}`}
527
+ style={{ fontFamily: 'inherit' }}
528
+ >
529
+ {label}
530
+ </button>
531
+ ))}
532
+ </div>
533
+ </div>
534
+
211
535
  {/* Images */}
212
536
  <div className="mb-8">
213
537
  <SectionLabel>Images <span className="normal-case tracking-normal font-normal text-text-3">(optional — reference designs, mockups, etc.)</span></SectionLabel>
@@ -341,6 +665,7 @@ export function TaskSheet() {
341
665
  <SectionLabel>Blocked By <span className="normal-case tracking-normal font-normal text-text-3">(tasks that must complete first)</span></SectionLabel>
342
666
  <select
343
667
  multiple
668
+ aria-label="Assign agents"
344
669
  value={blockedBy}
345
670
  onChange={(e) => setBlockedBy(Array.from(e.target.selectedOptions, (o) => o.value))}
346
671
  className="w-full px-4 py-3 rounded-[14px] border border-white/[0.08] bg-surface text-text text-[13px] outline-none min-h-[80px] focus-glow"
@@ -57,6 +57,13 @@ function formatCost(n: number): string {
57
57
  return `$${n.toFixed(4)}`
58
58
  }
59
59
 
60
+ function formatDuration(ms: number): string {
61
+ if (!ms) return '—'
62
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s`
63
+ if (ms < 3600_000) return `${Math.round(ms / 60_000)}m`
64
+ return `${(ms / 3600_000).toFixed(1)}h`
65
+ }
66
+
60
67
  function formatBucketLabel(bucket: string, range: Range): string {
61
68
  if (range === '24h') {
62
69
  // "2026-03-01T14" → "14:00"
@@ -128,7 +135,24 @@ export function MetricsDashboard() {
128
135
  // eslint-disable-next-line react-hooks/exhaustive-deps
129
136
  }, [])
130
137
 
138
+ // --- Task metrics ---
139
+ const [taskMetrics, setTaskMetrics] = useState<{
140
+ wip: number; completedCount: number; avgCycleMs: number
141
+ velocity: { bucket: string; count: number }[]
142
+ byAgent: { agentName: string; completed: number; failed: number }[]
143
+ } | null>(null)
144
+
145
+ const loadTaskMetrics = useCallback(async () => {
146
+ try {
147
+ const res = await api<typeof taskMetrics>('GET', `/tasks/metrics?range=${range}`)
148
+ setTaskMetrics(res)
149
+ } catch { /* ignore */ }
150
+ }, [range])
151
+
152
+ useEffect(() => { loadTaskMetrics() }, [loadTaskMetrics])
153
+
131
154
  useWs('usage', loadData, 30_000)
155
+ useWs('tasks', loadTaskMetrics, 15_000)
132
156
 
133
157
  const completionRate = computeCompletionRate(tasks)
134
158
 
@@ -154,20 +178,20 @@ export function MetricsDashboard() {
154
178
 
155
179
  const tooltipStyle = {
156
180
  contentStyle: {
157
- background: '#1a1a2e',
181
+ background: 'var(--color-surface)',
158
182
  border: '1px solid rgba(255,255,255,0.08)',
159
183
  borderRadius: 8,
160
184
  fontSize: 12,
161
- color: '#e0e0e0',
185
+ color: 'var(--color-text)',
162
186
  },
163
- itemStyle: { color: '#e0e0e0' },
164
- labelStyle: { color: '#a0a0b0' },
187
+ itemStyle: { color: 'var(--color-text)' },
188
+ labelStyle: { color: 'var(--color-text-2)' },
165
189
  }
166
190
 
167
191
  return (
168
192
  <div className="flex-1 flex flex-col h-full overflow-y-auto">
169
193
  <div className="px-8 pt-6 pb-4 shrink-0">
170
- <h1 className="font-display text-[28px] font-800 tracking-[-0.03em]">Usage</h1>
194
+ <h1 className="font-display text-[28px] font-700 tracking-[-0.03em]">Usage</h1>
171
195
  <p className="text-[13px] text-text-3 mt-1">Token usage, cost tracking &amp; agent performance</p>
172
196
  </div>
173
197
 
@@ -192,7 +216,10 @@ export function MetricsDashboard() {
192
216
 
193
217
  {loading && !data ? (
194
218
  <div className="flex-1 flex items-center justify-center">
195
- <p className="text-text-3 text-[13px]">Loading metrics…</p>
219
+ <div className="flex items-center gap-3">
220
+ <span className="w-5 h-5 rounded-full border-2 border-text-3/20 border-t-accent-bright animate-spin" />
221
+ <span className="text-[14px] text-text-3">Loading metrics...</span>
222
+ </div>
196
223
  </div>
197
224
  ) : (
198
225
  <div className="px-8 pb-8 space-y-6">
@@ -276,6 +303,63 @@ export function MetricsDashboard() {
276
303
  </ChartCard>
277
304
  </div>
278
305
 
306
+ {/* Task KPIs */}
307
+ {taskMetrics && (
308
+ <>
309
+ <h3 className="font-display text-[16px] font-700 text-text mt-2">Task Performance</h3>
310
+ <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
311
+ <StatCard label="Tasks Completed" value={String(taskMetrics.completedCount)} />
312
+ <StatCard label="Avg Cycle Time" value={formatDuration(taskMetrics.avgCycleMs)} />
313
+ <StatCard label="WIP" value={String(taskMetrics.wip)} />
314
+ <StatCard label="Completion Rate" value={`${completionRate}%`} />
315
+ </div>
316
+
317
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
318
+ <ChartCard title="Task Velocity">
319
+ {taskMetrics.velocity.length > 0 ? (
320
+ <ResponsiveContainer width="100%" height={280}>
321
+ <BarChart data={taskMetrics.velocity.map((v) => ({ ...v, label: formatBucketLabel(v.bucket, range) }))} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
322
+ <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
323
+ <XAxis dataKey="label" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} />
324
+ <YAxis tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} allowDecimals={false} />
325
+ <Tooltip {...tooltipStyle} formatter={(value: number | undefined) => [value ?? 0, 'Completed']} />
326
+ <Bar dataKey="count" fill="#34D399" radius={[4, 4, 0, 0]} />
327
+ </BarChart>
328
+ </ResponsiveContainer>
329
+ ) : (
330
+ <EmptyChart />
331
+ )}
332
+ </ChartCard>
333
+
334
+ <ChartCard title="Tasks by Agent">
335
+ {taskMetrics.byAgent.length > 0 ? (
336
+ <ResponsiveContainer width="100%" height={280}>
337
+ <BarChart
338
+ data={taskMetrics.byAgent.slice(0, 8).map((a) => ({
339
+ name: a.agentName.length > 12 ? a.agentName.slice(0, 12) + '…' : a.agentName,
340
+ completed: a.completed,
341
+ failed: a.failed,
342
+ }))}
343
+ margin={{ top: 5, right: 20, bottom: 5, left: 0 }}
344
+ >
345
+ <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
346
+ <XAxis dataKey="name" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} />
347
+ <YAxis tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} allowDecimals={false} />
348
+ <Tooltip {...tooltipStyle} />
349
+ <Bar dataKey="completed" fill="#34D399" radius={[4, 4, 0, 0]} stackId="a" name="Completed" />
350
+ <Bar dataKey="failed" fill="#F87171" radius={[4, 4, 0, 0]} stackId="a" name="Failed" />
351
+ <Legend verticalAlign="bottom" iconType="circle" iconSize={8}
352
+ formatter={(value: string) => <span style={{ color: '#a0a0b0', fontSize: 11 }}>{value}</span>} />
353
+ </BarChart>
354
+ </ResponsiveContainer>
355
+ ) : (
356
+ <EmptyChart />
357
+ )}
358
+ </ChartCard>
359
+ </div>
360
+ </>
361
+ )}
362
+
279
363
  {/* Provider Health */}
280
364
  {data?.providerHealth && Object.keys(data.providerHealth).length > 0 && (
281
365
  <div>
@@ -48,7 +48,7 @@ interface UseContinuousSpeechOptions {
48
48
 
49
49
  export function useContinuousSpeech(options: UseContinuousSpeechOptions) {
50
50
  const { lang, silenceDelayMs = 800, onUtterance } = options
51
- const [state, setState] = useState<ContinuousSpeechState>('idle')
51
+ const [state, _setState] = useState<ContinuousSpeechState>('idle')
52
52
  const [transcript, setTranscript] = useState('')
53
53
  const [interimText, setInterimText] = useState('')
54
54
 
@@ -56,6 +56,12 @@ export function useContinuousSpeech(options: UseContinuousSpeechOptions) {
56
56
  const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
57
57
  const activeRef = useRef(false)
58
58
  const accumulatedRef = useRef('')
59
+ const stateRef = useRef<ContinuousSpeechState>('idle')
60
+
61
+ const setState = useCallback((next: ContinuousSpeechState) => {
62
+ stateRef.current = next
63
+ _setState(next)
64
+ }, [])
59
65
 
60
66
  const clearSilenceTimer = () => {
61
67
  if (silenceTimerRef.current) {
@@ -122,7 +128,7 @@ export function useContinuousSpeech(options: UseContinuousSpeechOptions) {
122
128
 
123
129
  recog.onend = () => {
124
130
  // Auto-restart if still active (browser may stop recognition periodically)
125
- if (activeRef.current && state !== 'waitingForResponse') {
131
+ if (activeRef.current && stateRef.current !== 'waitingForResponse') {
126
132
  try { recog.start() } catch { /* noop */ }
127
133
  }
128
134
  }
@@ -156,7 +162,7 @@ export function useContinuousSpeech(options: UseContinuousSpeechOptions) {
156
162
  setTranscript('')
157
163
  setInterimText('')
158
164
  accumulatedRef.current = ''
159
- }, [])
165
+ }, [setState])
160
166
 
161
167
  const pause = useCallback(() => {
162
168
  clearSilenceTimer()
@@ -172,7 +178,7 @@ export function useContinuousSpeech(options: UseContinuousSpeechOptions) {
172
178
  setInterimText('')
173
179
  setState('listening')
174
180
  startRecognition()
175
- }, [startRecognition])
181
+ }, [startRecognition, setState])
176
182
 
177
183
  const supported = typeof window !== 'undefined' &&
178
184
  !!((window as unknown as WindowWithSpeechRecognition).SpeechRecognition || (window as unknown as WindowWithSpeechRecognition).webkitSpeechRecognition)