@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.
- package/LICENSE +21 -0
- package/README.md +2 -2
- package/package.json +2 -1
- package/public/screenshots/agents.png +0 -0
- package/public/screenshots/dashboard.png +0 -0
- package/public/screenshots/providers.png +0 -0
- package/public/screenshots/tasks.png +0 -0
- package/src/app/api/activity/route.ts +30 -0
- package/src/app/api/agents/[id]/route.ts +3 -1
- package/src/app/api/agents/route.ts +2 -1
- package/src/app/api/connectors/[id]/route.ts +4 -1
- package/src/app/api/openclaw/approvals/route.ts +20 -0
- package/src/app/api/tasks/[id]/route.ts +37 -1
- package/src/app/api/tasks/route.ts +7 -1
- package/src/app/api/usage/route.ts +74 -22
- package/src/app/api/webhooks/[id]/route.ts +62 -22
- package/src/cli/index.js +7 -0
- package/src/cli/spec.js +6 -0
- package/src/components/activity/activity-feed.tsx +91 -0
- package/src/components/chat/exec-approval-card.tsx +6 -3
- package/src/components/layout/app-layout.tsx +21 -7
- package/src/components/tasks/task-board.tsx +40 -2
- package/src/components/tasks/task-card.tsx +40 -2
- package/src/components/tasks/task-sheet.tsx +147 -1
- package/src/components/usage/metrics-dashboard.tsx +278 -0
- package/src/hooks/use-page-active.ts +21 -0
- package/src/hooks/use-ws.ts +13 -1
- package/src/lib/fetch-dedup.ts +20 -0
- package/src/lib/optimistic.ts +25 -0
- package/src/lib/server/connectors/manager.ts +18 -0
- package/src/lib/server/daemon-state.ts +205 -20
- package/src/lib/server/queue.ts +16 -0
- package/src/lib/server/storage.ts +34 -0
- package/src/lib/view-routes.ts +1 -0
- package/src/lib/ws-client.ts +2 -1
- package/src/stores/use-app-store.ts +48 -1
- package/src/stores/use-approval-store.ts +21 -7
- 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
|
-
{
|
|
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 !== '
|
|
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: '
|
|
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
|
-
|
|
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
|
|
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
|
|
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 } = {
|
|
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">×</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>
|