@swarmclawai/swarmclaw 0.5.1 → 0.5.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 (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2 -2
  3. package/package.json +2 -1
  4. package/public/screenshots/agents.png +0 -0
  5. package/public/screenshots/dashboard.png +0 -0
  6. package/public/screenshots/providers.png +0 -0
  7. package/public/screenshots/tasks.png +0 -0
  8. package/src/app/api/activity/route.ts +30 -0
  9. package/src/app/api/agents/[id]/route.ts +3 -1
  10. package/src/app/api/agents/route.ts +2 -1
  11. package/src/app/api/connectors/[id]/route.ts +4 -1
  12. package/src/app/api/openclaw/approvals/route.ts +20 -0
  13. package/src/app/api/tasks/[id]/route.ts +37 -1
  14. package/src/app/api/tasks/route.ts +7 -1
  15. package/src/app/api/usage/route.ts +74 -22
  16. package/src/app/api/webhooks/[id]/route.ts +62 -22
  17. package/src/cli/index.js +7 -0
  18. package/src/cli/spec.js +6 -0
  19. package/src/components/activity/activity-feed.tsx +91 -0
  20. package/src/components/chat/exec-approval-card.tsx +6 -3
  21. package/src/components/layout/app-layout.tsx +21 -7
  22. package/src/components/tasks/task-board.tsx +40 -2
  23. package/src/components/tasks/task-card.tsx +40 -2
  24. package/src/components/tasks/task-sheet.tsx +147 -1
  25. package/src/components/usage/metrics-dashboard.tsx +278 -0
  26. package/src/hooks/use-page-active.ts +21 -0
  27. package/src/hooks/use-ws.ts +13 -1
  28. package/src/lib/fetch-dedup.ts +20 -0
  29. package/src/lib/optimistic.ts +25 -0
  30. package/src/lib/server/connectors/manager.ts +18 -0
  31. package/src/lib/server/daemon-state.ts +205 -20
  32. package/src/lib/server/queue.ts +16 -0
  33. package/src/lib/server/storage.ts +34 -0
  34. package/src/lib/view-routes.ts +1 -0
  35. package/src/lib/ws-client.ts +2 -1
  36. package/src/stores/use-app-store.ts +48 -1
  37. package/src/stores/use-approval-store.ts +21 -7
  38. package/src/types/index.ts +40 -1
@@ -14,8 +14,9 @@ export function ExecApprovalCard({ approval }: Props) {
14
14
  resolveApproval(approval.id, decision)
15
15
  }
16
16
 
17
+ const alreadyResolved = approval.error?.includes('Already resolved') ?? false
17
18
  const expired = approval.expiresAtMs < Date.now()
18
- const disabled = !!approval.resolving || expired
19
+ const disabled = !!approval.resolving || expired || alreadyResolved
19
20
 
20
21
  return (
21
22
  <div className="my-2 rounded-[12px] border border-amber-500/20 bg-amber-500/[0.04] p-3.5">
@@ -47,11 +48,13 @@ export function ExecApprovalCard({ approval }: Props) {
47
48
  )}
48
49
  </div>
49
50
 
50
- {approval.error && (
51
+ {approval.error && !alreadyResolved && (
51
52
  <p className="text-[12px] text-red-400 mb-2">{approval.error}</p>
52
53
  )}
53
54
 
54
- {expired ? (
55
+ {alreadyResolved ? (
56
+ <p className="text-[12px] text-text-3/50 italic">Already resolved by another session</p>
57
+ ) : expired ? (
55
58
  <p className="text-[12px] text-text-3/50 italic">Approval expired</p>
56
59
  ) : (
57
60
  <div className="flex items-center gap-2">
@@ -35,8 +35,9 @@ import { KnowledgeList } from '@/components/knowledge/knowledge-list'
35
35
  import { KnowledgeSheet } from '@/components/knowledge/knowledge-sheet'
36
36
  import { PluginList } from '@/components/plugins/plugin-list'
37
37
  import { PluginSheet } from '@/components/plugins/plugin-sheet'
38
- import { UsageList } from '@/components/usage/usage-list'
39
38
  import { RunList } from '@/components/runs/run-list'
39
+ import { ActivityFeed } from '@/components/activity/activity-feed'
40
+ import { MetricsDashboard } from '@/components/usage/metrics-dashboard'
40
41
  import { ProjectList } from '@/components/projects/project-list'
41
42
  import { ProjectSheet } from '@/components/projects/project-sheet'
42
43
  import { NetworkBanner } from './network-banner'
@@ -314,6 +315,11 @@ export function AppLayout() {
314
315
  <polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
315
316
  </svg>
316
317
  </NavItem>
318
+ <NavItem view="activity" label="Activity" expanded={railExpanded} active={activeView} sidebarOpen={sidebarOpen} onClick={() => handleNavClick('activity')}>
319
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
320
+ <path d="M12 8v4l3 3" /><circle cx="12" cy="12" r="10" />
321
+ </svg>
322
+ </NavItem>
317
323
  <NavItem view="logs" label="Logs" expanded={railExpanded} active={activeView} sidebarOpen={sidebarOpen} onClick={() => handleNavClick('logs')}>
318
324
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
319
325
  <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /><line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" /><polyline points="10 9 9 9 8 9" />
@@ -463,7 +469,6 @@ export function AppLayout() {
463
469
  {activeView === 'webhooks' && <WebhookList inSidebar />}
464
470
  {activeView === 'mcp_servers' && <McpServerList />}
465
471
  {activeView === 'knowledge' && <KnowledgeList />}
466
- {activeView === 'usage' && <UsageList />}
467
472
  {activeView === 'runs' && <RunList />}
468
473
  {activeView === 'logs' && <LogList />}
469
474
  </div>
@@ -562,7 +567,6 @@ export function AppLayout() {
562
567
  {activeView === 'knowledge' && <KnowledgeList />}
563
568
  {activeView === 'plugins' && <PluginList inSidebar />}
564
569
  {activeView === 'projects' && <ProjectList />}
565
- {activeView === 'usage' && <UsageList />}
566
570
  {activeView === 'runs' && <RunList />}
567
571
  {activeView === 'logs' && <LogList />}
568
572
  </div>
@@ -596,6 +600,10 @@ export function AppLayout() {
596
600
  <TaskBoard />
597
601
  ) : activeView === 'memory' ? (
598
602
  <MemoryDetail />
603
+ ) : activeView === 'activity' ? (
604
+ <ActivityFeed />
605
+ ) : activeView === 'usage' ? (
606
+ <MetricsDashboard />
599
607
  ) : activeView === 'settings' ? (
600
608
  <SettingsPage />
601
609
  ) : !sidebarOpen && FULL_WIDTH_VIEWS.has(activeView) ? (
@@ -604,7 +612,7 @@ export function AppLayout() {
604
612
  <h2 className="font-display text-[14px] font-600 text-text-2 tracking-[-0.01em] capitalize flex-1">
605
613
  {activeView === 'mcp_servers' ? 'MCP Servers' : activeView.replace('_', ' ')}
606
614
  </h2>
607
- {activeView !== 'usage' && activeView !== 'runs' && activeView !== 'logs' && (
615
+ {activeView !== 'runs' && activeView !== 'logs' && (
608
616
  <button
609
617
  onClick={openNewSheet}
610
618
  className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-[8px] text-[11px] font-600 text-accent-bright bg-accent-soft hover:bg-[#6366F1]/15 transition-all cursor-pointer"
@@ -627,7 +635,6 @@ export function AppLayout() {
627
635
  {activeView === 'knowledge' && <KnowledgeList />}
628
636
  {activeView === 'plugins' && <PluginList />}
629
637
  {activeView === 'projects' && <ProjectList />}
630
- {activeView === 'usage' && <UsageList />}
631
638
  {activeView === 'runs' && <RunList />}
632
639
  {activeView === 'logs' && <LogList />}
633
640
  </div>
@@ -742,16 +749,17 @@ const VIEW_DESCRIPTIONS: Record<AppView, string> = {
742
749
  knowledge: 'Shared knowledge base accessible by all agents',
743
750
  logs: 'Application logs & error tracking',
744
751
  plugins: 'Extend agent capabilities with custom plugins',
745
- usage: 'Token usage analytics & cost tracking',
752
+ usage: 'Usage metrics, cost tracking & agent performance',
746
753
  runs: 'Live run monitoring & history',
747
754
  settings: 'Manage providers, API keys & orchestrator engine',
748
755
  projects: 'Group agents, tasks & schedules into projects',
756
+ activity: 'Audit trail of all entity mutations',
749
757
  }
750
758
 
751
759
  const FULL_WIDTH_VIEWS = new Set<AppView>([
752
760
  'schedules', 'secrets', 'providers', 'skills',
753
761
  'connectors', 'webhooks', 'mcp_servers', 'knowledge', 'plugins',
754
- 'usage', 'runs', 'logs', 'settings', 'projects',
762
+ 'usage', 'runs', 'logs', 'settings', 'projects', 'activity',
755
763
  ])
756
764
 
757
765
  const VIEW_EMPTY_STATES: Record<Exclude<AppView, 'agents'>, { icon: string; title: string; description: string; features: string[] }> = {
@@ -851,6 +859,12 @@ const VIEW_EMPTY_STATES: Record<Exclude<AppView, 'agents'>, { icon: string; titl
851
859
  description: 'Organize your work into projects. Group agents, tasks, and schedules under a common scope.',
852
860
  features: ['Create named projects with color badges', 'Assign agents and tasks to projects', 'Filter sidebar views by project', 'Global view when no filter is active'],
853
861
  },
862
+ activity: {
863
+ icon: 'clock',
864
+ title: 'Activity',
865
+ description: 'Audit trail of all entity mutations across the system.',
866
+ features: ['Track agent, task, and connector changes', 'Filter by entity type and action', 'Real-time updates via WebSocket', 'Relative timestamps'],
867
+ },
854
868
  }
855
869
 
856
870
  function ViewEmptyState({ view }: { view: AppView }) {
@@ -18,16 +18,40 @@ export function TaskBoard() {
18
18
  const agents = useAppStore((s) => s.agents)
19
19
  const showArchived = useAppStore((s) => s.showArchivedTasks)
20
20
  const setShowArchived = useAppStore((s) => s.setShowArchivedTasks)
21
- const [filterAgentId, setFilterAgentId] = useState<string>('')
21
+ // URL-based filter state
22
+ const [filterAgentId, setFilterAgentId] = useState<string>(() => {
23
+ if (typeof window === 'undefined') return ''
24
+ return new URLSearchParams(window.location.search).get('agent') || ''
25
+ })
26
+ const [filterTag, setFilterTag] = useState<string>(() => {
27
+ if (typeof window === 'undefined') return ''
28
+ return new URLSearchParams(window.location.search).get('tag') || ''
29
+ })
30
+
31
+ // Sync filters to URL
32
+ useEffect(() => {
33
+ if (typeof window === 'undefined') return
34
+ const params = new URLSearchParams()
35
+ if (filterAgentId) params.set('agent', filterAgentId)
36
+ if (filterTag) params.set('tag', filterTag)
37
+ const qs = params.toString()
38
+ const newUrl = `${window.location.pathname}${qs ? `?${qs}` : ''}`
39
+ window.history.replaceState(null, '', newUrl)
40
+ }, [filterAgentId, filterTag])
22
41
 
23
42
  useEffect(() => { loadTasks(); loadAgents() }, [])
24
43
  useWs('tasks', loadTasks, 5000)
25
44
 
45
+ // Collect all unique tags across tasks
46
+ const allTags = Array.from(new Set(Object.values(tasks).flatMap((t) => t.tags || []))).sort()
47
+
26
48
  const columns: BoardTaskStatus[] = showArchived ? [...ACTIVE_COLUMNS, 'archived'] : ACTIVE_COLUMNS
27
49
 
28
50
  const tasksByStatus = (status: BoardTaskStatus) =>
29
51
  Object.values(tasks)
30
- .filter((t) => t.status === status && (!filterAgentId || t.agentId === filterAgentId))
52
+ .filter((t) => t.status === status
53
+ && (!filterAgentId || t.agentId === filterAgentId)
54
+ && (!filterTag || (t.tags && t.tags.includes(filterTag))))
31
55
  .sort((a, b) => b.updatedAt - a.updatedAt)
32
56
 
33
57
  const handleDrop = useCallback(async (taskId: string, newStatus: BoardTaskStatus) => {
@@ -59,6 +83,20 @@ export function TaskBoard() {
59
83
  <option key={a.id} value={a.id}>{a.name}</option>
60
84
  ))}
61
85
  </select>
86
+ {allTags.length > 0 && (
87
+ <select
88
+ value={filterTag}
89
+ onChange={(e) => setFilterTag(e.target.value)}
90
+ className="px-3 py-2 rounded-[10px] text-[13px] font-600 cursor-pointer transition-all border
91
+ bg-transparent border-white/[0.06] text-text-3 hover:bg-white/[0.03] appearance-none"
92
+ style={{ fontFamily: 'inherit', minWidth: 110 }}
93
+ >
94
+ <option value="">All Tags</option>
95
+ {allTags.map((tag) => (
96
+ <option key={tag} value={tag}>{tag}</option>
97
+ ))}
98
+ </select>
99
+ )}
62
100
  <button
63
101
  onClick={() => setShowArchived(!showArchived)}
64
102
  className={`px-4 py-2 rounded-[10px] text-[13px] font-600 cursor-pointer transition-all border
@@ -4,7 +4,7 @@ import { useState, useCallback } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
5
  import { api } from '@/lib/api-client'
6
6
  import { updateTask, archiveTask } from '@/lib/tasks'
7
- import type { BoardTask, BoardTaskStatus } from '@/types'
7
+ import type { BoardTask } from '@/types'
8
8
 
9
9
  function timeAgo(ts: number) {
10
10
  const diff = Date.now() - ts
@@ -25,6 +25,14 @@ export function TaskCard({ task }: { task: BoardTask }) {
25
25
 
26
26
  const agent = agents[task.agentId]
27
27
 
28
+ const isBlocked = Array.isArray(task.blockedBy) && task.blockedBy.length > 0
29
+ const isOverdue = task.dueAt && task.dueAt < Date.now() && task.status !== 'completed' && task.status !== 'archived'
30
+ const borderColor = isBlocked ? 'border-l-rose-500'
31
+ : task.pendingApproval ? 'border-l-amber-500'
32
+ : task.status === 'running' ? 'border-l-emerald-500'
33
+ : task.status === 'failed' ? 'border-l-red-500'
34
+ : 'border-l-transparent'
35
+
28
36
  const handleQueue = async (e: React.MouseEvent) => {
29
37
  e.stopPropagation()
30
38
  await updateTask(task.id, { status: 'queued' })
@@ -64,17 +72,47 @@ export function TaskCard({ task }: { task: BoardTask }) {
64
72
  setEditingTaskId(task.id)
65
73
  setTaskSheetOpen(true)
66
74
  }}
67
- className={`p-4 rounded-[14px] border border-white/[0.06] bg-surface hover:bg-surface-2 cursor-grab active:cursor-grabbing
75
+ className={`p-4 rounded-[14px] border border-white/[0.06] border-l-[3px] ${borderColor} bg-surface hover:bg-surface-2 cursor-grab active:cursor-grabbing
68
76
  transition-all group ${dragging ? 'opacity-40 scale-[0.97]' : ''}`}
69
77
  >
70
78
  <div className="flex items-start gap-3 mb-3">
79
+ {isBlocked && (
80
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-rose-400 shrink-0 mt-0.5">
81
+ <title>{`Blocked by ${task.blockedBy?.length} task(s)`}</title>
82
+ <rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" />
83
+ </svg>
84
+ )}
71
85
  <h4 className="flex-1 text-[14px] font-600 text-text leading-[1.4] line-clamp-2">{task.title}</h4>
86
+ {isBlocked && (
87
+ <span className="px-1.5 py-0.5 rounded-[5px] bg-rose-500/10 text-rose-400 text-[10px] font-600 shrink-0">
88
+ {task.blockedBy?.length}
89
+ </span>
90
+ )}
72
91
  </div>
73
92
 
74
93
  {task.description && (
75
94
  <p className="text-[12px] text-text-3 line-clamp-2 mb-3">{task.description}</p>
76
95
  )}
77
96
 
97
+ {/* Tags */}
98
+ {task.tags && task.tags.length > 0 && (
99
+ <div className="flex flex-wrap gap-1 mb-3">
100
+ {task.tags.map((tag) => (
101
+ <span key={tag} className="px-1.5 py-0.5 rounded-[5px] bg-indigo-500/10 text-indigo-400 text-[10px] font-600">
102
+ {tag}
103
+ </span>
104
+ ))}
105
+ </div>
106
+ )}
107
+
108
+ {/* Due date */}
109
+ {task.dueAt && (
110
+ <p className={`text-[11px] mb-3 font-600 ${isOverdue ? 'text-red-400' : 'text-text-3/60'}`}>
111
+ Due {new Date(task.dueAt).toLocaleDateString([], { month: 'short', day: 'numeric' })}
112
+ {isOverdue && ' (overdue)'}
113
+ </p>
114
+ )}
115
+
78
116
  {task.images && task.images.length > 0 && (
79
117
  <div className="flex gap-1.5 mb-3 overflow-x-auto">
80
118
  {task.images.slice(0, 3).map((url, i) => (
@@ -25,6 +25,9 @@ export function TaskSheet() {
25
25
  const agents = useAppStore((s) => s.agents)
26
26
  const loadAgents = useAppStore((s) => s.loadAgents)
27
27
 
28
+ const appSettings = useAppStore((s) => s.appSettings)
29
+ const loadSettings = useAppStore((s) => s.loadSettings)
30
+
28
31
  const [title, setTitle] = useState('')
29
32
  const [description, setDescription] = useState('')
30
33
  const [agentId, setAgentId] = useState('')
@@ -33,6 +36,11 @@ export function TaskSheet() {
33
36
  const [uploading, setUploading] = useState(false)
34
37
  const [cwd, setCwd] = useState('')
35
38
  const [file, setFile] = useState<string | null>(null)
39
+ const [tags, setTags] = useState<string[]>([])
40
+ const [tagInput, setTagInput] = useState('')
41
+ const [blockedBy, setBlockedBy] = useState<string[]>([])
42
+ const [dueAt, setDueAt] = useState<string>('')
43
+ const [customFields, setCustomFields] = useState<Record<string, string | number | boolean>>({})
36
44
 
37
45
  const editing = editingId ? tasks[editingId] : null
38
46
  const agentList = Object.values(agents)
@@ -40,6 +48,7 @@ export function TaskSheet() {
40
48
  useEffect(() => {
41
49
  if (open) {
42
50
  loadAgents()
51
+ loadSettings()
43
52
  if (editing) {
44
53
  setTitle(editing.title)
45
54
  setDescription(editing.description)
@@ -47,6 +56,10 @@ export function TaskSheet() {
47
56
  setImages(editing.images || [])
48
57
  setCwd(editing.cwd || '')
49
58
  setFile(editing.file || null)
59
+ setTags(editing.tags || [])
60
+ setBlockedBy(editing.blockedBy || [])
61
+ setDueAt(editing.dueAt ? new Date(editing.dueAt).toISOString().slice(0, 10) : '')
62
+ setCustomFields(editing.customFields || {})
50
63
  } else {
51
64
  setTitle('')
52
65
  setDescription('')
@@ -54,8 +67,13 @@ export function TaskSheet() {
54
67
  setImages([])
55
68
  setCwd('')
56
69
  setFile(null)
70
+ setTags([])
71
+ setBlockedBy([])
72
+ setDueAt('')
73
+ setCustomFields({})
57
74
  }
58
75
  }
76
+ // eslint-disable-next-line react-hooks/exhaustive-deps
59
77
  }, [open, editingId])
60
78
 
61
79
  // Update default agent when agents load (only if no agent selected yet)
@@ -71,7 +89,12 @@ export function TaskSheet() {
71
89
  }
72
90
 
73
91
  const handleSave = async () => {
74
- const payload: Partial<BoardTask> & { title: string; description: string; agentId: string } = { title: title.trim() || 'Untitled Task', description, agentId, images, cwd: cwd || undefined, file: file || undefined }
92
+ const payload: Partial<BoardTask> & { title: string; description: string; agentId: string } = {
93
+ title: title.trim() || 'Untitled Task', description, agentId, images,
94
+ cwd: cwd || undefined, file: file || undefined,
95
+ tags, blockedBy, dueAt: dueAt ? new Date(dueAt).getTime() : null,
96
+ customFields: Object.keys(customFields).length > 0 ? customFields : undefined,
97
+ }
75
98
  if (editing) {
76
99
  await updateTask(editing.id, payload)
77
100
  } else {
@@ -248,6 +271,129 @@ export function TaskSheet() {
248
271
  />
249
272
  </div>
250
273
 
274
+ {/* Tags */}
275
+ <div className="mb-8">
276
+ <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">
277
+ Tags <span className="normal-case tracking-normal font-normal text-text-3">(optional)</span>
278
+ </label>
279
+ {tags.length > 0 && (
280
+ <div className="flex flex-wrap gap-1.5 mb-3">
281
+ {tags.map((tag) => (
282
+ <span key={tag} className="inline-flex items-center gap-1 px-2 py-1 rounded-[8px] bg-indigo-500/10 text-indigo-400 text-[12px] font-600">
283
+ {tag}
284
+ <button onClick={() => setTags((prev) => prev.filter((t) => t !== tag))} className="text-indigo-400/60 hover:text-indigo-400 cursor-pointer border-none bg-transparent p-0 text-[14px] leading-none">&times;</button>
285
+ </span>
286
+ ))}
287
+ </div>
288
+ )}
289
+ <div className="relative">
290
+ <input
291
+ type="text"
292
+ value={tagInput}
293
+ onChange={(e) => setTagInput(e.target.value)}
294
+ onKeyDown={(e) => {
295
+ if (e.key === 'Enter' && tagInput.trim()) {
296
+ e.preventDefault()
297
+ const t = tagInput.trim().toLowerCase()
298
+ if (!tags.includes(t)) setTags((prev) => [...prev, t])
299
+ setTagInput('')
300
+ }
301
+ }}
302
+ placeholder="Type and press Enter to add..."
303
+ className={inputClass}
304
+ style={{ fontFamily: 'inherit' }}
305
+ list="tag-suggestions"
306
+ />
307
+ <datalist id="tag-suggestions">
308
+ {Array.from(new Set(Object.values(tasks).flatMap((t) => t.tags || [])))
309
+ .filter((t) => !tags.includes(t) && t.includes(tagInput.toLowerCase()))
310
+ .slice(0, 10)
311
+ .map((t) => <option key={t} value={t} />)}
312
+ </datalist>
313
+ </div>
314
+ </div>
315
+
316
+ {/* Dependencies */}
317
+ <div className="mb-8">
318
+ <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">
319
+ Blocked By <span className="normal-case tracking-normal font-normal text-text-3">(tasks that must complete first)</span>
320
+ </label>
321
+ <select
322
+ multiple
323
+ value={blockedBy}
324
+ onChange={(e) => setBlockedBy(Array.from(e.target.selectedOptions, (o) => o.value))}
325
+ 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"
326
+ style={{ fontFamily: 'inherit' }}
327
+ >
328
+ {Object.values(tasks)
329
+ .filter((t) => t.id !== editingId && t.status !== 'archived')
330
+ .map((t) => <option key={t.id} value={t.id}>{t.title} ({t.status})</option>)}
331
+ </select>
332
+ {editing && Array.isArray(editing.blocks) && editing.blocks.length > 0 && (
333
+ <div className="mt-3">
334
+ <span className="text-[11px] font-600 text-text-3 uppercase tracking-[0.06em]">Blocks:</span>
335
+ <div className="flex flex-wrap gap-1.5 mt-1.5">
336
+ {editing.blocks.map((bid) => {
337
+ const bt = tasks[bid]
338
+ return bt ? (
339
+ <span key={bid} className="px-2 py-1 rounded-[6px] bg-white/[0.04] text-text-3 text-[11px] font-600">{bt.title}</span>
340
+ ) : null
341
+ })}
342
+ </div>
343
+ </div>
344
+ )}
345
+ </div>
346
+
347
+ {/* Due Date */}
348
+ <div className="mb-8">
349
+ <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">
350
+ Due Date <span className="normal-case tracking-normal font-normal text-text-3">(optional)</span>
351
+ </label>
352
+ <input
353
+ type="date"
354
+ value={dueAt}
355
+ onChange={(e) => setDueAt(e.target.value)}
356
+ className={`${inputClass} appearance-none`}
357
+ style={{ fontFamily: 'inherit', colorScheme: 'dark' }}
358
+ />
359
+ </div>
360
+
361
+ {/* Custom Fields */}
362
+ {appSettings.taskCustomFieldDefs && appSettings.taskCustomFieldDefs.length > 0 && (
363
+ <div className="mb-8">
364
+ <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Custom Fields</label>
365
+ <div className="space-y-4">
366
+ {appSettings.taskCustomFieldDefs.map((def) => (
367
+ <div key={def.key}>
368
+ <label className="block text-[12px] text-text-3 mb-1.5">{def.label}</label>
369
+ {def.type === 'select' ? (
370
+ <select
371
+ value={String(customFields[def.key] ?? '')}
372
+ onChange={(e) => setCustomFields((prev) => ({ ...prev, [def.key]: e.target.value }))}
373
+ className={inputClass}
374
+ style={{ fontFamily: 'inherit' }}
375
+ >
376
+ <option value="">—</option>
377
+ {def.options?.map((opt) => <option key={opt} value={opt}>{opt}</option>)}
378
+ </select>
379
+ ) : (
380
+ <input
381
+ type={def.type === 'number' ? 'number' : 'text'}
382
+ value={String(customFields[def.key] ?? '')}
383
+ onChange={(e) => setCustomFields((prev) => ({
384
+ ...prev,
385
+ [def.key]: def.type === 'number' ? (e.target.value === '' ? '' : Number(e.target.value)) : e.target.value,
386
+ }))}
387
+ className={inputClass}
388
+ style={{ fontFamily: 'inherit' }}
389
+ />
390
+ )}
391
+ </div>
392
+ ))}
393
+ </div>
394
+ </div>
395
+ )}
396
+
251
397
  {editing?.result && (
252
398
  <div className="mb-8">
253
399
  <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Result</label>