@swarmclawai/swarmclaw 0.4.0 → 0.4.5

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 (144) hide show
  1. package/README.md +13 -2
  2. package/next.config.ts +8 -0
  3. package/package.json +2 -1
  4. package/src/app/api/agents/[id]/route.ts +20 -21
  5. package/src/app/api/agents/[id]/thread/route.ts +2 -2
  6. package/src/app/api/agents/route.ts +3 -2
  7. package/src/app/api/clawhub/install/route.ts +2 -2
  8. package/src/app/api/connectors/[id]/route.ts +10 -3
  9. package/src/app/api/connectors/[id]/webhook/route.ts +99 -0
  10. package/src/app/api/connectors/route.ts +6 -3
  11. package/src/app/api/credentials/[id]/route.ts +2 -1
  12. package/src/app/api/credentials/route.ts +2 -2
  13. package/src/app/api/documents/route.ts +2 -2
  14. package/src/app/api/files/serve/route.ts +8 -0
  15. package/src/app/api/knowledge/[id]/route.ts +5 -4
  16. package/src/app/api/knowledge/upload/route.ts +2 -2
  17. package/src/app/api/mcp-servers/[id]/route.ts +11 -14
  18. package/src/app/api/mcp-servers/[id]/test/route.ts +2 -1
  19. package/src/app/api/mcp-servers/[id]/tools/route.ts +2 -1
  20. package/src/app/api/mcp-servers/route.ts +2 -2
  21. package/src/app/api/memory/[id]/route.ts +9 -8
  22. package/src/app/api/memory/route.ts +2 -2
  23. package/src/app/api/memory-images/[filename]/route.ts +2 -1
  24. package/src/app/api/openclaw/directory/route.ts +26 -0
  25. package/src/app/api/openclaw/discover/route.ts +61 -0
  26. package/src/app/api/openclaw/sync/route.ts +30 -0
  27. package/src/app/api/orchestrator/run/route.ts +2 -2
  28. package/src/app/api/projects/[id]/route.ts +55 -0
  29. package/src/app/api/projects/route.ts +27 -0
  30. package/src/app/api/providers/[id]/models/route.ts +2 -1
  31. package/src/app/api/providers/[id]/route.ts +13 -15
  32. package/src/app/api/providers/route.ts +2 -2
  33. package/src/app/api/schedules/[id]/route.ts +16 -18
  34. package/src/app/api/schedules/[id]/run/route.ts +4 -3
  35. package/src/app/api/schedules/route.ts +2 -2
  36. package/src/app/api/secrets/[id]/route.ts +16 -17
  37. package/src/app/api/secrets/route.ts +2 -2
  38. package/src/app/api/sessions/[id]/clear/route.ts +2 -1
  39. package/src/app/api/sessions/[id]/deploy/route.ts +2 -1
  40. package/src/app/api/sessions/[id]/devserver/route.ts +2 -1
  41. package/src/app/api/sessions/[id]/messages/route.ts +2 -1
  42. package/src/app/api/sessions/[id]/retry/route.ts +2 -1
  43. package/src/app/api/sessions/[id]/route.ts +2 -1
  44. package/src/app/api/sessions/route.ts +2 -2
  45. package/src/app/api/skills/[id]/route.ts +23 -21
  46. package/src/app/api/skills/import/route.ts +2 -2
  47. package/src/app/api/skills/route.ts +2 -2
  48. package/src/app/api/tasks/[id]/approve/route.ts +2 -1
  49. package/src/app/api/tasks/[id]/route.ts +6 -5
  50. package/src/app/api/tasks/route.ts +2 -2
  51. package/src/app/api/tts/stream/route.ts +48 -0
  52. package/src/app/api/upload/route.ts +2 -2
  53. package/src/app/api/uploads/[filename]/route.ts +4 -1
  54. package/src/app/api/webhooks/[id]/route.ts +29 -31
  55. package/src/app/api/webhooks/route.ts +2 -2
  56. package/src/app/page.tsx +3 -24
  57. package/src/cli/index.js +28 -0
  58. package/src/cli/index.ts +1 -1
  59. package/src/cli/spec.js +2 -0
  60. package/src/components/agents/agent-list.tsx +3 -1
  61. package/src/components/agents/agent-sheet.tsx +116 -14
  62. package/src/components/chat/chat-area.tsx +27 -4
  63. package/src/components/chat/chat-header.tsx +141 -29
  64. package/src/components/chat/tool-call-bubble.tsx +9 -3
  65. package/src/components/chat/voice-overlay.tsx +80 -0
  66. package/src/components/connectors/connector-list.tsx +6 -2
  67. package/src/components/connectors/connector-sheet.tsx +31 -7
  68. package/src/components/layout/app-layout.tsx +47 -25
  69. package/src/components/projects/project-list.tsx +122 -0
  70. package/src/components/projects/project-sheet.tsx +135 -0
  71. package/src/components/schedules/schedule-list.tsx +3 -1
  72. package/src/components/sessions/new-session-sheet.tsx +6 -6
  73. package/src/components/sessions/session-card.tsx +1 -1
  74. package/src/components/sessions/session-list.tsx +7 -7
  75. package/src/components/shared/connector-platform-icon.tsx +4 -0
  76. package/src/components/shared/settings/section-heartbeat.tsx +1 -1
  77. package/src/components/shared/settings/section-orchestrator.tsx +1 -2
  78. package/src/components/shared/settings/section-web-search.tsx +56 -0
  79. package/src/components/shared/settings/settings-page.tsx +73 -0
  80. package/src/components/skills/skill-list.tsx +2 -1
  81. package/src/components/tasks/task-list.tsx +5 -2
  82. package/src/hooks/use-continuous-speech.ts +144 -0
  83. package/src/hooks/use-view-router.ts +52 -0
  84. package/src/hooks/use-voice-conversation.ts +80 -0
  85. package/src/lib/id.ts +6 -0
  86. package/src/lib/projects.ts +13 -0
  87. package/src/lib/provider-sets.ts +5 -0
  88. package/src/lib/providers/anthropic.ts +14 -1
  89. package/src/lib/providers/index.ts +6 -0
  90. package/src/lib/providers/ollama.ts +9 -1
  91. package/src/lib/providers/openai.ts +9 -1
  92. package/src/lib/providers/openclaw.ts +11 -0
  93. package/src/lib/server/api-routes.test.ts +5 -6
  94. package/src/lib/server/build-llm.ts +17 -4
  95. package/src/lib/server/chat-execution.ts +38 -4
  96. package/src/lib/server/collection-helpers.ts +54 -0
  97. package/src/lib/server/connectors/bluebubbles.test.ts +208 -0
  98. package/src/lib/server/connectors/bluebubbles.ts +357 -0
  99. package/src/lib/server/connectors/connector-routing.test.ts +1 -1
  100. package/src/lib/server/connectors/googlechat.ts +46 -7
  101. package/src/lib/server/connectors/manager.ts +392 -3
  102. package/src/lib/server/connectors/media.ts +2 -2
  103. package/src/lib/server/connectors/openclaw.ts +64 -0
  104. package/src/lib/server/connectors/pairing.test.ts +99 -0
  105. package/src/lib/server/connectors/pairing.ts +256 -0
  106. package/src/lib/server/connectors/signal.ts +1 -0
  107. package/src/lib/server/connectors/teams.ts +5 -5
  108. package/src/lib/server/connectors/types.ts +10 -0
  109. package/src/lib/server/execution-log.ts +3 -3
  110. package/src/lib/server/heartbeat-service.ts +1 -1
  111. package/src/lib/server/knowledge-db.test.ts +2 -33
  112. package/src/lib/server/main-agent-loop.ts +6 -6
  113. package/src/lib/server/memory-db.ts +6 -6
  114. package/src/lib/server/openclaw-approvals.ts +105 -0
  115. package/src/lib/server/openclaw-sync.ts +496 -0
  116. package/src/lib/server/orchestrator-lg.ts +30 -9
  117. package/src/lib/server/orchestrator.ts +4 -4
  118. package/src/lib/server/process-manager.ts +2 -2
  119. package/src/lib/server/queue.ts +22 -10
  120. package/src/lib/server/scheduler.ts +2 -2
  121. package/src/lib/server/session-mailbox.ts +2 -2
  122. package/src/lib/server/session-run-manager.ts +2 -2
  123. package/src/lib/server/session-tools/connector.ts +51 -4
  124. package/src/lib/server/session-tools/crud.ts +3 -3
  125. package/src/lib/server/session-tools/delegate.ts +3 -3
  126. package/src/lib/server/session-tools/file.ts +176 -3
  127. package/src/lib/server/session-tools/index.ts +2 -0
  128. package/src/lib/server/session-tools/memory.ts +2 -2
  129. package/src/lib/server/session-tools/openclaw-nodes.ts +112 -0
  130. package/src/lib/server/session-tools/sandbox.ts +33 -0
  131. package/src/lib/server/session-tools/search-providers.ts +270 -0
  132. package/src/lib/server/session-tools/session-info.ts +2 -2
  133. package/src/lib/server/session-tools/web.ts +47 -66
  134. package/src/lib/server/storage.ts +12 -0
  135. package/src/lib/server/stream-agent-chat.ts +29 -0
  136. package/src/lib/server/task-result.test.ts +44 -0
  137. package/src/lib/server/task-result.ts +14 -0
  138. package/src/lib/tool-definitions.ts +5 -3
  139. package/src/lib/tts-stream.ts +130 -0
  140. package/src/lib/view-routes.ts +28 -0
  141. package/src/proxy.ts +3 -0
  142. package/src/stores/use-app-store.ts +28 -1
  143. package/src/stores/use-chat-store.ts +9 -1
  144. package/src/types/index.ts +27 -2
@@ -0,0 +1,80 @@
1
+ 'use client'
2
+
3
+ import type { VoiceConversationState } from '@/hooks/use-voice-conversation'
4
+
5
+ interface VoiceOverlayProps {
6
+ state: VoiceConversationState
7
+ interimText: string
8
+ transcript: string
9
+ onStop: () => void
10
+ }
11
+
12
+ const STATE_LABELS: Record<VoiceConversationState, string> = {
13
+ idle: '',
14
+ listening: 'Listening...',
15
+ processing: 'Processing...',
16
+ speaking: 'Speaking...',
17
+ }
18
+
19
+ export function VoiceOverlay({ state, interimText, transcript, onStop }: VoiceOverlayProps) {
20
+ if (state === 'idle') return null
21
+
22
+ return (
23
+ <div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-4 bg-bg/90 backdrop-blur-sm">
24
+ {/* Animated indicator */}
25
+ <div className="relative">
26
+ <div className={`w-20 h-20 rounded-full flex items-center justify-center ${
27
+ state === 'listening'
28
+ ? 'bg-accent/20 animate-pulse'
29
+ : state === 'speaking'
30
+ ? 'bg-green-500/20'
31
+ : 'bg-yellow-500/20'
32
+ }`}>
33
+ <div className={`w-12 h-12 rounded-full flex items-center justify-center ${
34
+ state === 'listening'
35
+ ? 'bg-accent/30'
36
+ : state === 'speaking'
37
+ ? 'bg-green-500/30'
38
+ : 'bg-yellow-500/30'
39
+ }`}>
40
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className={
41
+ state === 'listening' ? 'text-accent-bright' : state === 'speaking' ? 'text-green-400' : 'text-yellow-400'
42
+ }>
43
+ {state === 'speaking' ? (
44
+ <>
45
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
46
+ <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
47
+ <path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
48
+ </>
49
+ ) : (
50
+ <>
51
+ <path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
52
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
53
+ <line x1="12" x2="12" y1="19" y2="22" />
54
+ </>
55
+ )}
56
+ </svg>
57
+ </div>
58
+ </div>
59
+ </div>
60
+
61
+ <div className="text-[14px] font-500 text-text-2">{STATE_LABELS[state]}</div>
62
+
63
+ {/* Transcript display */}
64
+ {(transcript || interimText) && (
65
+ <div className="max-w-md px-6 text-center">
66
+ {transcript && <p className="text-[14px] text-text-1 mb-1">{transcript}</p>}
67
+ {interimText && <p className="text-[13px] text-text-3/60 italic">{interimText}</p>}
68
+ </div>
69
+ )}
70
+
71
+ {/* Stop button */}
72
+ <button
73
+ onClick={onStop}
74
+ className="mt-2 px-5 py-2 rounded-lg bg-red-500/10 text-red-400 text-[13px] font-600 hover:bg-red-500/20 transition-colors"
75
+ >
76
+ Stop Voice
77
+ </button>
78
+ </div>
79
+ )
80
+ }
@@ -104,8 +104,12 @@ export function ConnectorList({ inSidebar: _inSidebar }: { inSidebar?: boolean }
104
104
  const agent = agents[c.agentId]
105
105
  const isRunning = c.status === 'running'
106
106
  const isToggling = toggling === c.id
107
- // Can only toggle if connector has credentials (or is WhatsApp which uses QR)
108
- const hasCredentials = c.platform === 'whatsapp' || !!c.credentialId
107
+ // Can only toggle if connector has credentials (or uses non-token auth modes).
108
+ const hasCredentials = c.platform === 'whatsapp'
109
+ || c.platform === 'openclaw'
110
+ || c.platform === 'signal'
111
+ || (c.platform === 'bluebubbles' && (!!c.credentialId || !!c.config?.password))
112
+ || !!c.credentialId
109
113
  return (
110
114
  <div
111
115
  key={c.id}
@@ -114,7 +114,7 @@ const PLATFORMS: {
114
114
  tokenHelp: 'Required when your OpenClaw gateway is auth-protected',
115
115
  configFields: [
116
116
  { key: 'wsUrl', label: 'WebSocket URL', placeholder: 'ws://localhost:18789', help: 'OpenClaw gateway WebSocket endpoint (root URL, not /ws)' },
117
- { key: 'sessionKey', label: 'Session Key Filter', placeholder: 'main', help: 'Optional. If set, only inbound events for this OpenClaw session are processed.' },
117
+ { key: 'sessionKey', label: 'Chat Key Filter', placeholder: 'main', help: 'Optional. If set, only inbound events for this OpenClaw session are processed.' },
118
118
  { key: 'nodeId', label: 'Client Label', placeholder: 'swarmclaw', help: 'Optional display label shown in OpenClaw presence metadata.' },
119
119
  { key: 'role', label: 'Gateway Role', placeholder: 'operator', help: 'Optional role claim for connect handshake. Default is operator.' },
120
120
  { key: 'scopes', label: 'Scopes (CSV)', placeholder: 'operator.read,operator.write', help: 'Optional comma-separated scopes for OpenClaw connect.' },
@@ -122,6 +122,28 @@ const PLATFORMS: {
122
122
  { key: 'tickIntervalMs', label: 'Tick Interval Override (ms)', placeholder: '30000', help: 'Optional watchdog interval override when policy tick is unavailable.' },
123
123
  ],
124
124
  },
125
+ {
126
+ id: 'bluebubbles',
127
+ label: 'BlueBubbles',
128
+ color: '#2E89FF',
129
+ setupSteps: [
130
+ 'Run BlueBubbles server on your macOS host and enable the REST API',
131
+ 'Copy the BlueBubbles server password',
132
+ 'After saving the connector, point BlueBubbles webhook to /api/connectors/<connector-id>/webhook',
133
+ 'Optionally set dmPolicy=pairing to require explicit sender approval for new DMs',
134
+ ],
135
+ tokenLabel: 'BlueBubbles Password',
136
+ tokenHelp: 'Server password used for /api/v1/ping and /api/v1/message/text',
137
+ configFields: [
138
+ { key: 'serverUrl', label: 'Server URL', placeholder: 'http://127.0.0.1:1234', help: 'BlueBubbles server URL (no trailing /api path needed)' },
139
+ { key: 'chatIds', label: 'Allowed Chat IDs', placeholder: 'iMessage;-;+15551234567', help: 'Optional comma-separated chat IDs/guid fragments. Leave empty for all chats.' },
140
+ { key: 'dmPolicy', label: 'DM Policy', placeholder: 'open | allowlist | pairing | disabled', help: 'Access policy for direct-message senders. Default: open.' },
141
+ { key: 'allowFrom', label: 'Allowed Sender IDs', placeholder: '+15551234567,test@example.com', help: 'Optional comma-separated sender IDs for allowlist/pairing mode.' },
142
+ { key: 'outboundTarget', label: 'Default Outbound Target', placeholder: 'iMessage;-;+15551234567', help: 'Used when proactive sends omit "to".' },
143
+ { key: 'webhookSecret', label: 'Webhook Secret', placeholder: 'optional-shared-secret', help: 'Optional secret required by /api/connectors/{id}/webhook (header: x-connector-secret or ?secret=...)' },
144
+ { key: 'timeoutMs', label: 'Request Timeout (ms)', placeholder: '10000', help: 'Optional BlueBubbles API timeout in milliseconds.' },
145
+ ],
146
+ },
125
147
  {
126
148
  id: 'matrix',
127
149
  label: 'Matrix',
@@ -146,13 +168,14 @@ const PLATFORMS: {
146
168
  setupSteps: [
147
169
  'Create a Google Cloud project and enable the Google Chat API',
148
170
  'Create a service account and download the JSON key file',
149
- 'In Google Chat Admin, configure the bot with your app URL',
171
+ 'In Google Chat Admin, configure event delivery to /api/connectors/<connector-id>/webhook',
150
172
  'Paste the full service account JSON as the bot token',
151
173
  ],
152
174
  tokenLabel: 'Service Account JSON',
153
175
  tokenHelp: 'Paste the full service account JSON key file contents',
154
176
  configFields: [
155
177
  { key: 'spaceIds', label: 'Space IDs', placeholder: 'spaces/AAAA123', help: 'Comma-separated Google Chat space IDs' },
178
+ { key: 'webhookSecret', label: 'Webhook Secret', placeholder: 'optional-shared-secret', help: 'Optional secret required by /api/connectors/{id}/webhook (header: x-connector-secret or ?secret=...)' },
156
179
  ],
157
180
  },
158
181
  {
@@ -163,13 +186,14 @@ const PLATFORMS: {
163
186
  'Register a bot in the Azure Bot Framework portal',
164
187
  'Note the Microsoft App ID and generate an App Secret',
165
188
  'Set up a public HTTPS endpoint for webhook delivery',
166
- 'Configure the messaging endpoint in Azure to your notify URL',
189
+ 'After saving the connector, point Azure to /api/connectors/<connector-id>/webhook',
167
190
  ],
168
191
  tokenLabel: 'App Secret',
169
192
  tokenHelp: 'Microsoft App Secret from Azure Bot registration',
170
193
  configFields: [
171
194
  { key: 'appId', label: 'Microsoft App ID', placeholder: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', help: 'Azure Bot Framework App ID' },
172
- { key: 'notifyUrl', label: 'Notify URL', placeholder: 'https://your-server.com/api/teams/webhook', help: 'Public HTTPS endpoint for receiving messages' },
195
+ { key: 'notifyUrl', label: 'Notify URL', placeholder: 'https://your-server.com/api/connectors/<id>/webhook', help: 'Public HTTPS endpoint for receiving messages (informational)' },
196
+ { key: 'webhookSecret', label: 'Webhook Secret', placeholder: 'optional-shared-secret', help: 'Optional secret required by /api/connectors/{id}/webhook (header: x-connector-secret or ?secret=...)' },
173
197
  ],
174
198
  },
175
199
  {
@@ -366,7 +390,7 @@ export function ConnectorSheet() {
366
390
  <div>
367
391
  <div className={`text-[14px] font-600 ${platform === p.id ? 'text-text' : 'text-text-2'}`}>{p.label}</div>
368
392
  <div className="text-[11px] text-text-3 mt-0.5">
369
- {p.id === 'whatsapp' ? 'QR code pairing' : p.id === 'openclaw' ? 'WebSocket gateway' : p.id === 'signal' ? 'signal-cli binary' : p.id === 'matrix' ? 'Access token' : p.id === 'googlechat' ? 'Service account' : p.id === 'teams' ? 'Bot Framework' : 'Bot token'}
393
+ {p.id === 'whatsapp' ? 'QR code pairing' : p.id === 'openclaw' ? 'WebSocket gateway' : p.id === 'bluebubbles' ? 'iMessage bridge' : p.id === 'signal' ? 'signal-cli binary' : p.id === 'matrix' ? 'Access token' : p.id === 'googlechat' ? 'Service account' : p.id === 'teams' ? 'Bot Framework' : 'Bot token'}
370
394
  </div>
371
395
  </div>
372
396
  </button>
@@ -552,7 +576,7 @@ export function ConnectorSheet() {
552
576
 
553
577
  {/* Platform-specific config */}
554
578
  {platformConfig.configFields.map((field) => {
555
- const isTagField = field.key === 'allowedJids' || field.key === 'channelIds' || field.key === 'chatIds'
579
+ const isTagField = field.key === 'allowedJids' || field.key === 'channelIds' || field.key === 'chatIds' || field.key === 'allowFrom'
556
580
  if (isTagField) {
557
581
  const tags = (config[field.key] || '').split(',').map((s) => s.trim()).filter(Boolean)
558
582
  return (
@@ -732,7 +756,7 @@ export function ConnectorSheet() {
732
756
  </div>
733
757
  <p className="text-[11px] text-text-3">
734
758
  {waHasCreds
735
- ? 'Reconnecting with saved session, this should only take a moment'
759
+ ? 'Reconnecting with saved credentials, this should only take a moment'
736
760
  : 'Connecting to WhatsApp, QR code will appear shortly'}
737
761
  </p>
738
762
  {waHasCreds && (
@@ -6,7 +6,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
6
6
  import { useAppStore } from '@/stores/use-app-store'
7
7
  import { useMediaQuery } from '@/hooks/use-media-query'
8
8
  import { Avatar } from '@/components/shared/avatar'
9
- import { SettingsSheet } from '@/components/shared/settings-sheet'
9
+ import { SettingsPage } from '@/components/shared/settings/settings-page'
10
10
  import { AgentList } from '@/components/agents/agent-list'
11
11
  import { AgentChatList } from '@/components/agents/agent-chat-list'
12
12
  import { AgentSheet } from '@/components/agents/agent-sheet'
@@ -37,6 +37,8 @@ import { PluginList } from '@/components/plugins/plugin-list'
37
37
  import { PluginSheet } from '@/components/plugins/plugin-sheet'
38
38
  import { UsageList } from '@/components/usage/usage-list'
39
39
  import { RunList } from '@/components/runs/run-list'
40
+ import { ProjectList } from '@/components/projects/project-list'
41
+ import { ProjectSheet } from '@/components/projects/project-sheet'
40
42
  import { NetworkBanner } from './network-banner'
41
43
  import { UpdateBanner } from './update-banner'
42
44
  import { MobileHeader } from './mobile-header'
@@ -53,7 +55,6 @@ export function AppLayout() {
53
55
  const currentSessionId = useAppStore((s) => s.currentSessionId)
54
56
  const sidebarOpen = useAppStore((s) => s.sidebarOpen)
55
57
  const setSidebarOpen = useAppStore((s) => s.setSidebarOpen)
56
- const setSettingsOpen = useAppStore((s) => s.setSettingsOpen)
57
58
  const setUser = useAppStore((s) => s.setUser)
58
59
  const setCurrentSession = useAppStore((s) => s.setCurrentSession)
59
60
  const activeView = useAppStore((s) => s.activeView)
@@ -69,6 +70,7 @@ export function AppLayout() {
69
70
  const setMcpServerSheetOpen = useAppStore((s) => s.setMcpServerSheetOpen)
70
71
  const setKnowledgeSheetOpen = useAppStore((s) => s.setKnowledgeSheetOpen)
71
72
  const setPluginSheetOpen = useAppStore((s) => s.setPluginSheetOpen)
73
+ const setProjectSheetOpen = useAppStore((s) => s.setProjectSheetOpen)
72
74
  const tasks = useAppStore((s) => s.tasks)
73
75
  const isDesktop = useMediaQuery('(min-width: 768px)')
74
76
  const hasSelectedSession = !!(currentSessionId && sessions[currentSessionId])
@@ -128,6 +130,7 @@ export function AppLayout() {
128
130
  else if (activeView === 'mcp_servers') setMcpServerSheetOpen(true)
129
131
  else if (activeView === 'knowledge') setKnowledgeSheetOpen(true)
130
132
  else if (activeView === 'plugins') setPluginSheetOpen(true)
133
+ else if (activeView === 'projects') setProjectSheetOpen(true)
131
134
  }
132
135
 
133
136
  const handleNavClick = (view: AppView) => {
@@ -145,15 +148,11 @@ export function AppLayout() {
145
148
  const agents = useAppStore((s) => s.agents)
146
149
  const currentAgentId = useAppStore((s) => s.currentAgentId)
147
150
  const setCurrentAgent = useAppStore((s) => s.setCurrentAgent)
148
- const mainSession = Object.values(sessions).find((s) => s.name === '__main__' && s.user === currentUser)
149
-
150
151
  const goToMainChat = async () => {
151
152
  // Navigate to default agent's chat thread
152
153
  const defaultAgent = agents['default'] || Object.values(agents)[0]
153
154
  if (defaultAgent) {
154
155
  await setCurrentAgent(defaultAgent.id)
155
- } else if (mainSession) {
156
- setCurrentSession(mainSession.id)
157
156
  }
158
157
  setActiveView('agents')
159
158
  setSidebarOpen(false)
@@ -245,6 +244,11 @@ export function AppLayout() {
245
244
  <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /><circle cx="12" cy="7" r="4" />
246
245
  </svg>
247
246
  </NavItem>
247
+ <NavItem view="projects" label="Projects" expanded={railExpanded} active={activeView} sidebarOpen={sidebarOpen} onClick={() => handleNavClick('projects')}>
248
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
249
+ <path d="M2 20a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8l-7-7H4a2 2 0 0 0-2 2v17Z" /><path d="M14 2v7h7" />
250
+ </svg>
251
+ </NavItem>
248
252
  <NavItem view="schedules" label="Schedules" expanded={railExpanded} active={activeView} sidebarOpen={sidebarOpen} onClick={() => handleNavClick('schedules')}>
249
253
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
250
254
  <circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
@@ -355,7 +359,7 @@ export function AppLayout() {
355
359
  {railExpanded && <DaemonIndicator />}
356
360
  {railExpanded ? (
357
361
  <button
358
- onClick={() => setSettingsOpen(true)}
362
+ onClick={() => handleNavClick('settings')}
359
363
  className="w-full flex items-center gap-2.5 px-3 py-2 rounded-[10px] text-[13px] font-500 cursor-pointer transition-all
360
364
  bg-transparent text-text-3 hover:text-text hover:bg-white/[0.04] border-none"
361
365
  style={{ fontFamily: 'inherit' }}
@@ -368,7 +372,7 @@ export function AppLayout() {
368
372
  </button>
369
373
  ) : (
370
374
  <RailTooltip label="Settings" description="API keys, providers & app config">
371
- <button onClick={() => setSettingsOpen(true)} className="rail-btn">
375
+ <button onClick={() => handleNavClick('settings')} className="rail-btn">
372
376
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
373
377
  <circle cx="12" cy="12" r="3" />
374
378
  <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
@@ -486,7 +490,7 @@ export function AppLayout() {
486
490
  <path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" /><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
487
491
  </svg>
488
492
  </a>
489
- <button onClick={() => setSettingsOpen(true)} className="rail-btn">
493
+ <button onClick={() => handleNavClick('settings')} className="rail-btn">
490
494
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
491
495
  <circle cx="12" cy="12" r="3" />
492
496
  <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
@@ -512,7 +516,7 @@ export function AppLayout() {
512
516
  </button>
513
517
  ))}
514
518
  </div>
515
- {activeView !== 'logs' && activeView !== 'usage' && activeView !== 'runs' && (
519
+ {activeView !== 'logs' && activeView !== 'usage' && activeView !== 'runs' && activeView !== 'settings' && (
516
520
  <div className="px-4 py-2.5 shrink-0">
517
521
  <button
518
522
  onClick={() => {
@@ -524,7 +528,7 @@ export function AppLayout() {
524
528
  shadow-[0_2px_12px_rgba(99,102,241,0.15)]"
525
529
  style={{ fontFamily: 'inherit' }}
526
530
  >
527
- + New {activeView === 'agents' ? 'Agent' : activeView === 'schedules' ? 'Schedule' : activeView === 'tasks' ? 'Task' : activeView === 'secrets' ? 'Secret' : activeView === 'providers' ? 'Provider' : activeView === 'skills' ? 'Skill' : activeView === 'connectors' ? 'Connector' : activeView === 'webhooks' ? 'Webhook' : activeView === 'mcp_servers' ? 'MCP Server' : activeView === 'knowledge' ? 'Knowledge' : activeView === 'plugins' ? 'Plugin' : 'Entry'}
531
+ + New {activeView === 'agents' ? 'Agent' : activeView === 'schedules' ? 'Schedule' : activeView === 'tasks' ? 'Task' : activeView === 'secrets' ? 'Secret' : activeView === 'providers' ? 'Provider' : activeView === 'skills' ? 'Skill' : activeView === 'connectors' ? 'Connector' : activeView === 'webhooks' ? 'Webhook' : activeView === 'mcp_servers' ? 'MCP Server' : activeView === 'knowledge' ? 'Knowledge' : activeView === 'plugins' ? 'Plugin' : activeView === 'projects' ? 'Project' : 'Entry'}
528
532
  </button>
529
533
  </div>
530
534
  )}
@@ -557,6 +561,7 @@ export function AppLayout() {
557
561
  {activeView === 'mcp_servers' && <McpServerList />}
558
562
  {activeView === 'knowledge' && <KnowledgeList />}
559
563
  {activeView === 'plugins' && <PluginList inSidebar />}
564
+ {activeView === 'projects' && <ProjectList />}
560
565
  {activeView === 'usage' && <UsageList />}
561
566
  {activeView === 'runs' && <RunList />}
562
567
  {activeView === 'logs' && <LogList />}
@@ -591,6 +596,8 @@ export function AppLayout() {
591
596
  <TaskBoard />
592
597
  ) : activeView === 'memory' ? (
593
598
  <MemoryDetail />
599
+ ) : activeView === 'settings' ? (
600
+ <SettingsPage />
594
601
  ) : !sidebarOpen && FULL_WIDTH_VIEWS.has(activeView) ? (
595
602
  <div className="flex-1 flex flex-col h-full">
596
603
  <div className="flex items-center px-6 pt-5 pb-3 shrink-0">
@@ -606,7 +613,7 @@ export function AppLayout() {
606
613
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
607
614
  <line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
608
615
  </svg>
609
- {activeView === 'schedules' ? 'Schedule' : activeView === 'secrets' ? 'Secret' : activeView === 'providers' ? 'Provider' : activeView === 'skills' ? 'Skill' : activeView === 'connectors' ? 'Connector' : activeView === 'webhooks' ? 'Webhook' : activeView === 'mcp_servers' ? 'MCP Server' : activeView === 'knowledge' ? 'Knowledge' : activeView === 'plugins' ? 'Plugin' : 'New'}
616
+ {activeView === 'schedules' ? 'Schedule' : activeView === 'secrets' ? 'Secret' : activeView === 'providers' ? 'Provider' : activeView === 'skills' ? 'Skill' : activeView === 'connectors' ? 'Connector' : activeView === 'webhooks' ? 'Webhook' : activeView === 'mcp_servers' ? 'MCP Server' : activeView === 'knowledge' ? 'Knowledge' : activeView === 'plugins' ? 'Plugin' : activeView === 'projects' ? 'Project' : 'New'}
610
617
  </button>
611
618
  )}
612
619
  </div>
@@ -619,6 +626,7 @@ export function AppLayout() {
619
626
  {activeView === 'mcp_servers' && <McpServerList />}
620
627
  {activeView === 'knowledge' && <KnowledgeList />}
621
628
  {activeView === 'plugins' && <PluginList />}
629
+ {activeView === 'projects' && <ProjectList />}
622
630
  {activeView === 'usage' && <UsageList />}
623
631
  {activeView === 'runs' && <RunList />}
624
632
  {activeView === 'logs' && <LogList />}
@@ -629,7 +637,6 @@ export function AppLayout() {
629
637
  </div>
630
638
  </ErrorBoundary>
631
639
 
632
- <SettingsSheet />
633
640
  <AgentSheet />
634
641
  <ScheduleSheet />
635
642
  <MemorySheet />
@@ -642,6 +649,7 @@ export function AppLayout() {
642
649
  <McpServerSheet />
643
650
  <KnowledgeSheet />
644
651
  <PluginSheet />
652
+ <ProjectSheet />
645
653
 
646
654
  <Dialog open={shortcutsOpen} onOpenChange={setShortcutsOpen}>
647
655
  <DialogContent className="sm:max-w-[380px] bg-raised border-white/[0.08]">
@@ -735,13 +743,15 @@ const VIEW_DESCRIPTIONS: Record<AppView, string> = {
735
743
  logs: 'Application logs & error tracking',
736
744
  plugins: 'Extend agent capabilities with custom plugins',
737
745
  usage: 'Token usage analytics & cost tracking',
738
- runs: 'Live session run monitoring & history',
746
+ runs: 'Live run monitoring & history',
747
+ settings: 'Manage providers, API keys & orchestrator engine',
748
+ projects: 'Group agents, tasks & schedules into projects',
739
749
  }
740
750
 
741
751
  const FULL_WIDTH_VIEWS = new Set<AppView>([
742
752
  'schedules', 'secrets', 'providers', 'skills',
743
753
  'connectors', 'webhooks', 'mcp_servers', 'knowledge', 'plugins',
744
- 'usage', 'runs', 'logs',
754
+ 'usage', 'runs', 'logs', 'settings', 'projects',
745
755
  ])
746
756
 
747
757
  const VIEW_EMPTY_STATES: Record<Exclude<AppView, 'agents'>, { icon: string; title: string; description: string; features: string[] }> = {
@@ -754,14 +764,14 @@ const VIEW_EMPTY_STATES: Record<Exclude<AppView, 'agents'>, { icon: string; titl
754
764
  memory: {
755
765
  icon: 'database',
756
766
  title: 'Memory',
757
- description: 'Long-term memory store for AI agents. Orchestrators can store and retrieve knowledge across sessions.',
758
- features: ['Agents store findings and learnings automatically', 'Full-text search across all stored memories', 'Organized by categories and agents', 'Persists across sessions for continuity'],
767
+ description: 'Long-term memory store for AI agents. Orchestrators can store and retrieve knowledge across conversations.',
768
+ features: ['Agents store findings and learnings automatically', 'Full-text search across all stored memories', 'Organized by categories and agents', 'Persists across conversations for continuity'],
759
769
  },
760
770
  tasks: {
761
771
  icon: 'clipboard',
762
772
  title: 'Task Board',
763
773
  description: 'A Trello-style board for managing orchestrator jobs. Create tasks, assign them to orchestrators, and track progress.',
764
- features: ['Kanban columns: Backlog, Queued, Running, Completed, Failed', 'Assign tasks to specific orchestrator agents', 'Sequential queue ensures orchestrators don\'t conflict', 'View results and session logs for completed tasks'],
774
+ features: ['Kanban columns: Backlog, Queued, Running, Completed, Failed', 'Assign tasks to specific orchestrator agents', 'Sequential queue ensures orchestrators don\'t conflict', 'View results and logs for completed tasks'],
765
775
  },
766
776
  secrets: {
767
777
  icon: 'lock',
@@ -785,7 +795,7 @@ const VIEW_EMPTY_STATES: Record<Exclude<AppView, 'agents'>, { icon: string; titl
785
795
  icon: 'link',
786
796
  title: 'Connectors',
787
797
  description: 'Bridge chat platforms to your AI agents. Receive messages from Discord, Telegram, Slack, or WhatsApp and route them to agents.',
788
- features: ['Connect Discord, Telegram, Slack, or WhatsApp bots', 'Route incoming messages to any agent', 'Each platform channel gets its own session', 'Start and stop connectors from the UI'],
798
+ features: ['Connect Discord, Telegram, Slack, or WhatsApp bots', 'Route incoming messages to any agent', 'Each platform channel gets its own chat thread', 'Start and stop connectors from the UI'],
789
799
  },
790
800
  webhooks: {
791
801
  icon: 'webhook',
@@ -796,7 +806,7 @@ const VIEW_EMPTY_STATES: Record<Exclude<AppView, 'agents'>, { icon: string; titl
796
806
  mcp_servers: {
797
807
  icon: 'server',
798
808
  title: 'MCP Servers',
799
- description: 'Connect agents to external MCP (Model Context Protocol) servers, injecting their tools into chat sessions.',
809
+ description: 'Connect agents to external MCP (Model Context Protocol) servers, injecting their tools into agent chats.',
800
810
  features: ['Configure stdio, SSE, or streamable HTTP transports', 'Test connections and discover available tools', 'Assign MCP servers to specific agents', 'Tools appear alongside built-in tools in chat'],
801
811
  },
802
812
  knowledge: {
@@ -820,15 +830,27 @@ const VIEW_EMPTY_STATES: Record<Exclude<AppView, 'agents'>, { icon: string; titl
820
830
  usage: {
821
831
  icon: 'bar-chart',
822
832
  title: 'Usage',
823
- description: 'Track token usage and costs across all providers and sessions.',
824
- features: ['Per-provider cost breakdown', 'Token usage over time', 'Session-level cost tracking', 'Export usage data'],
833
+ description: 'Track token usage and costs across all providers and agents.',
834
+ features: ['Per-provider cost breakdown', 'Token usage over time', 'Per-agent cost tracking', 'Export usage data'],
825
835
  },
826
836
  runs: {
827
837
  icon: 'activity',
828
838
  title: 'Runs',
829
- description: 'View the session run queue and execution history.',
839
+ description: 'View the run queue and execution history.',
830
840
  features: ['Monitor queued and running tasks', 'View run results and errors', 'Cancel pending runs', 'Automatic retry tracking'],
831
841
  },
842
+ settings: {
843
+ icon: 'settings',
844
+ title: 'Settings',
845
+ description: 'Manage providers, API keys & orchestrator engine.',
846
+ features: ['Configure LLM providers', 'Manage API credentials', 'Tune orchestrator settings', 'Set up voice & embedding'],
847
+ },
848
+ projects: {
849
+ icon: 'folder',
850
+ title: 'Projects',
851
+ description: 'Organize your work into projects. Group agents, tasks, and schedules under a common scope.',
852
+ 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
+ },
832
854
  }
833
855
 
834
856
  function ViewEmptyState({ view }: { view: AppView }) {
@@ -1016,7 +1038,7 @@ function DesktopEmptyState({ userName }: { userName: string | null }) {
1016
1038
  <span className="text-text-2">What would you like to do?</span>
1017
1039
  </h1>
1018
1040
  <p className="text-[15px] text-text-3 mb-12">
1019
- Create a new session to start chatting
1041
+ Create a new chat to start chatting
1020
1042
  </p>
1021
1043
  <button
1022
1044
  onClick={() => setNewSessionOpen(true)}
@@ -1029,7 +1051,7 @@ function DesktopEmptyState({ userName }: { userName: string | null }) {
1029
1051
  <line x1="12" y1="5" x2="12" y2="19" />
1030
1052
  <line x1="5" y1="12" x2="19" y2="12" />
1031
1053
  </svg>
1032
- New Session
1054
+ New Chat
1033
1055
  </button>
1034
1056
  </div>
1035
1057
  </div>
@@ -0,0 +1,122 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useMemo, useState } from 'react'
4
+ import { useAppStore } from '@/stores/use-app-store'
5
+
6
+ export function ProjectList() {
7
+ const projects = useAppStore((s) => s.projects)
8
+ const loadProjects = useAppStore((s) => s.loadProjects)
9
+ const agents = useAppStore((s) => s.agents)
10
+ const tasks = useAppStore((s) => s.tasks)
11
+ const setProjectSheetOpen = useAppStore((s) => s.setProjectSheetOpen)
12
+ const setEditingProjectId = useAppStore((s) => s.setEditingProjectId)
13
+ const activeProjectFilter = useAppStore((s) => s.activeProjectFilter)
14
+ const setActiveProjectFilter = useAppStore((s) => s.setActiveProjectFilter)
15
+ const [search, setSearch] = useState('')
16
+
17
+ useEffect(() => { loadProjects() }, [])
18
+
19
+ const filtered = useMemo(() => {
20
+ return Object.values(projects)
21
+ .filter((p) => {
22
+ if (search && !p.name.toLowerCase().includes(search.toLowerCase())) return false
23
+ return true
24
+ })
25
+ .sort((a, b) => b.updatedAt - a.updatedAt)
26
+ }, [projects, search])
27
+
28
+ const entityCounts = useMemo(() => {
29
+ const counts: Record<string, { agents: number; tasks: number }> = {}
30
+ for (const p of Object.values(projects)) {
31
+ counts[p.id] = { agents: 0, tasks: 0 }
32
+ }
33
+ for (const a of Object.values(agents)) {
34
+ if (a.projectId && counts[a.projectId]) counts[a.projectId].agents++
35
+ }
36
+ for (const t of Object.values(tasks)) {
37
+ if (t.projectId && counts[t.projectId]) counts[t.projectId].tasks++
38
+ }
39
+ return counts
40
+ }, [projects, agents, tasks])
41
+
42
+ if (!filtered.length && !search) {
43
+ return (
44
+ <div className="flex-1 flex flex-col items-center justify-center gap-4 text-text-3 p-8 text-center">
45
+ <div className="w-12 h-12 rounded-[14px] bg-accent-soft flex items-center justify-center mb-1">
46
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-accent-bright">
47
+ <path d="M2 20a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8l-7-7H4a2 2 0 0 0-2 2v17Z" />
48
+ <path d="M14 2v7h7" />
49
+ </svg>
50
+ </div>
51
+ <p className="font-display text-[15px] font-600 text-text-2">No projects yet</p>
52
+ <p className="text-[13px] text-text-3/50">Group agents, tasks, and schedules into projects</p>
53
+ <button
54
+ onClick={() => { setEditingProjectId(null); setProjectSheetOpen(true) }}
55
+ className="inline-flex items-center gap-1.5 px-4 py-2 text-[13px] font-500 text-white bg-accent rounded-lg hover:bg-accent-bright transition-colors"
56
+ >
57
+ <span className="text-lg leading-none">+</span> New Project
58
+ </button>
59
+ </div>
60
+ )
61
+ }
62
+
63
+ return (
64
+ <div className="flex-1 flex flex-col h-full overflow-y-auto">
65
+ <div className="p-4 pb-0">
66
+ <div className="flex items-center gap-2 mb-4">
67
+ <input
68
+ type="text"
69
+ value={search}
70
+ onChange={(e) => setSearch(e.target.value)}
71
+ placeholder="Search projects..."
72
+ className="flex-1 px-3 py-2 rounded-lg bg-white/[0.06] border border-white/[0.06] text-[13px] text-text-1 placeholder:text-text-3/40 focus:outline-none focus:border-accent/40"
73
+ style={{ fontFamily: 'inherit' }}
74
+ />
75
+ </div>
76
+ </div>
77
+ <div className="flex-1 overflow-y-auto px-4 pb-4 space-y-2">
78
+ {filtered.map((project) => {
79
+ const counts = entityCounts[project.id] || { agents: 0, tasks: 0 }
80
+ const isActive = activeProjectFilter === project.id
81
+ return (
82
+ <div
83
+ key={project.id}
84
+ className={`group relative p-4 rounded-xl border transition-colors cursor-pointer ${
85
+ isActive
86
+ ? 'bg-accent/10 border-accent/30'
87
+ : 'bg-white/[0.03] border-white/[0.06] hover:bg-white/[0.06]'
88
+ }`}
89
+ onClick={() => setActiveProjectFilter(isActive ? null : project.id)}
90
+ >
91
+ <div className="flex items-start justify-between gap-3">
92
+ <div className="flex items-center gap-2.5 min-w-0">
93
+ {project.color && (
94
+ <div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: project.color }} />
95
+ )}
96
+ <div className="min-w-0">
97
+ <div className="font-display text-[14px] font-600 text-text-1 truncate">{project.name}</div>
98
+ {project.description && (
99
+ <p className="text-[12px] text-text-3/60 mt-0.5 line-clamp-2">{project.description}</p>
100
+ )}
101
+ </div>
102
+ </div>
103
+ <button
104
+ onClick={(e) => { e.stopPropagation(); setEditingProjectId(project.id); setProjectSheetOpen(true) }}
105
+ className="opacity-0 group-hover:opacity-100 p-1.5 rounded-md hover:bg-white/[0.08] transition-all text-text-3/50 hover:text-text-2"
106
+ >
107
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
108
+ <path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
109
+ </svg>
110
+ </button>
111
+ </div>
112
+ <div className="flex items-center gap-3 mt-2.5 text-[11px] text-text-3/50">
113
+ <span>{counts.agents} agent{counts.agents !== 1 ? 's' : ''}</span>
114
+ <span>{counts.tasks} task{counts.tasks !== 1 ? 's' : ''}</span>
115
+ </div>
116
+ </div>
117
+ )
118
+ })}
119
+ </div>
120
+ </div>
121
+ )
122
+ }