@swarmclawai/swarmclaw 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/README.md +39 -8
  2. package/bin/swarmclaw.js +76 -16
  3. package/next.config.ts +11 -1
  4. package/package.json +4 -2
  5. package/scripts/postinstall.mjs +18 -0
  6. package/src/app/api/chatrooms/[id]/chat/route.ts +410 -0
  7. package/src/app/api/chatrooms/[id]/members/route.ts +82 -0
  8. package/src/app/api/chatrooms/[id]/pins/route.ts +39 -0
  9. package/src/app/api/chatrooms/[id]/reactions/route.ts +42 -0
  10. package/src/app/api/chatrooms/[id]/route.ts +84 -0
  11. package/src/app/api/chatrooms/route.ts +50 -0
  12. package/src/app/api/credentials/route.ts +2 -3
  13. package/src/app/api/knowledge/[id]/route.ts +13 -2
  14. package/src/app/api/knowledge/route.ts +8 -1
  15. package/src/app/api/memory/route.ts +8 -0
  16. package/src/app/api/notifications/route.ts +4 -0
  17. package/src/app/api/orchestrator/run/route.ts +1 -1
  18. package/src/app/api/plugins/install/route.ts +2 -2
  19. package/src/app/api/search/route.ts +51 -1
  20. package/src/app/api/sessions/[id]/chat/route.ts +2 -0
  21. package/src/app/api/sessions/[id]/edit-resend/route.ts +1 -1
  22. package/src/app/api/sessions/[id]/fork/route.ts +1 -1
  23. package/src/app/api/sessions/route.ts +3 -3
  24. package/src/app/api/settings/route.ts +9 -0
  25. package/src/app/api/setup/check-provider/route.ts +3 -16
  26. package/src/app/api/skills/[id]/route.ts +6 -0
  27. package/src/app/api/skills/route.ts +6 -0
  28. package/src/app/api/tasks/[id]/route.ts +12 -0
  29. package/src/app/api/tasks/bulk/route.ts +100 -0
  30. package/src/app/api/tasks/route.ts +1 -0
  31. package/src/app/api/webhooks/[id]/route.ts +15 -1
  32. package/src/app/globals.css +58 -15
  33. package/src/app/page.tsx +142 -13
  34. package/src/cli/index.js +24 -0
  35. package/src/cli/index.test.js +30 -0
  36. package/src/cli/spec.js +16 -0
  37. package/src/components/agents/agent-avatar.tsx +57 -10
  38. package/src/components/agents/agent-card.tsx +48 -15
  39. package/src/components/agents/agent-chat-list.tsx +123 -10
  40. package/src/components/agents/agent-list.tsx +50 -19
  41. package/src/components/agents/agent-sheet.tsx +56 -63
  42. package/src/components/auth/access-key-gate.tsx +10 -3
  43. package/src/components/auth/setup-wizard.tsx +2 -2
  44. package/src/components/auth/user-picker.tsx +31 -3
  45. package/src/components/chat/activity-moment.tsx +169 -0
  46. package/src/components/chat/chat-header.tsx +2 -0
  47. package/src/components/chat/chat-tool-toggles.tsx +1 -1
  48. package/src/components/chat/file-path-chip.tsx +125 -0
  49. package/src/components/chat/markdown-utils.ts +9 -0
  50. package/src/components/chat/message-bubble.tsx +46 -295
  51. package/src/components/chat/message-list.tsx +50 -1
  52. package/src/components/chat/streaming-bubble.tsx +36 -46
  53. package/src/components/chat/suggestions-bar.tsx +1 -1
  54. package/src/components/chat/thinking-indicator.tsx +72 -10
  55. package/src/components/chat/tool-call-bubble.tsx +66 -70
  56. package/src/components/chat/tool-request-banner.tsx +31 -7
  57. package/src/components/chat/transfer-agent-picker.tsx +63 -0
  58. package/src/components/chatrooms/agent-hover-card.tsx +124 -0
  59. package/src/components/chatrooms/chatroom-input.tsx +320 -0
  60. package/src/components/chatrooms/chatroom-list.tsx +123 -0
  61. package/src/components/chatrooms/chatroom-message.tsx +427 -0
  62. package/src/components/chatrooms/chatroom-sheet.tsx +215 -0
  63. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +134 -0
  64. package/src/components/chatrooms/chatroom-typing-bar.tsx +88 -0
  65. package/src/components/chatrooms/chatroom-view.tsx +344 -0
  66. package/src/components/chatrooms/reaction-picker.tsx +273 -0
  67. package/src/components/connectors/connector-sheet.tsx +34 -47
  68. package/src/components/home/home-view.tsx +501 -0
  69. package/src/components/input/chat-input.tsx +79 -41
  70. package/src/components/knowledge/knowledge-list.tsx +31 -1
  71. package/src/components/knowledge/knowledge-sheet.tsx +83 -2
  72. package/src/components/layout/app-layout.tsx +175 -95
  73. package/src/components/layout/update-banner.tsx +2 -2
  74. package/src/components/logs/log-list.tsx +2 -2
  75. package/src/components/mcp-servers/mcp-server-sheet.tsx +1 -1
  76. package/src/components/memory/memory-agent-list.tsx +143 -0
  77. package/src/components/memory/memory-browser.tsx +205 -0
  78. package/src/components/memory/memory-card.tsx +34 -7
  79. package/src/components/memory/memory-detail.tsx +359 -120
  80. package/src/components/memory/memory-sheet.tsx +157 -23
  81. package/src/components/plugins/plugin-list.tsx +1 -1
  82. package/src/components/plugins/plugin-sheet.tsx +1 -1
  83. package/src/components/projects/project-detail.tsx +509 -0
  84. package/src/components/projects/project-list.tsx +195 -59
  85. package/src/components/providers/provider-list.tsx +2 -2
  86. package/src/components/providers/provider-sheet.tsx +3 -3
  87. package/src/components/schedules/schedule-card.tsx +1 -1
  88. package/src/components/schedules/schedule-list.tsx +1 -1
  89. package/src/components/schedules/schedule-sheet.tsx +25 -25
  90. package/src/components/secrets/secret-sheet.tsx +47 -24
  91. package/src/components/secrets/secrets-list.tsx +18 -8
  92. package/src/components/sessions/new-session-sheet.tsx +33 -65
  93. package/src/components/sessions/session-card.tsx +45 -14
  94. package/src/components/sessions/session-list.tsx +35 -18
  95. package/src/components/shared/agent-picker-list.tsx +90 -0
  96. package/src/components/shared/agent-switch-dialog.tsx +156 -0
  97. package/src/components/shared/attachment-chip.tsx +165 -0
  98. package/src/components/shared/avatar.tsx +10 -1
  99. package/src/components/shared/check-icon.tsx +12 -0
  100. package/src/components/shared/confirm-dialog.tsx +1 -1
  101. package/src/components/shared/empty-state.tsx +32 -0
  102. package/src/components/shared/file-preview.tsx +34 -0
  103. package/src/components/shared/form-styles.ts +2 -0
  104. package/src/components/shared/keyboard-shortcuts-dialog.tsx +116 -0
  105. package/src/components/shared/notification-center.tsx +44 -6
  106. package/src/components/shared/profile-sheet.tsx +115 -0
  107. package/src/components/shared/reply-quote.tsx +26 -0
  108. package/src/components/shared/search-dialog.tsx +14 -5
  109. package/src/components/shared/section-label.tsx +12 -0
  110. package/src/components/shared/settings/plugin-manager.tsx +1 -1
  111. package/src/components/shared/settings/section-providers.tsx +1 -1
  112. package/src/components/shared/settings/section-secrets.tsx +1 -1
  113. package/src/components/shared/settings/section-theme.tsx +95 -0
  114. package/src/components/shared/settings/section-user-preferences.tsx +39 -0
  115. package/src/components/shared/settings/settings-page.tsx +180 -27
  116. package/src/components/shared/settings/settings-sheet.tsx +9 -73
  117. package/src/components/shared/sheet-footer.tsx +33 -0
  118. package/src/components/skills/skill-list.tsx +61 -30
  119. package/src/components/skills/skill-sheet.tsx +81 -2
  120. package/src/components/tasks/task-board.tsx +448 -26
  121. package/src/components/tasks/task-card.tsx +46 -9
  122. package/src/components/tasks/task-column.tsx +62 -3
  123. package/src/components/tasks/task-list.tsx +12 -4
  124. package/src/components/tasks/task-sheet.tsx +89 -72
  125. package/src/components/ui/hover-card.tsx +52 -0
  126. package/src/components/usage/usage-list.tsx +1 -1
  127. package/src/components/webhooks/webhook-sheet.tsx +1 -1
  128. package/src/hooks/use-view-router.ts +69 -19
  129. package/src/instrumentation.ts +15 -1
  130. package/src/lib/chat.ts +2 -0
  131. package/src/lib/memory.ts +3 -0
  132. package/src/lib/server/chat-execution.ts +24 -4
  133. package/src/lib/server/connectors/manager.ts +11 -0
  134. package/src/lib/server/context-manager.ts +225 -13
  135. package/src/lib/server/create-notification.ts +14 -2
  136. package/src/lib/server/daemon-state.ts +157 -10
  137. package/src/lib/server/execution-log.ts +1 -0
  138. package/src/lib/server/heartbeat-service.ts +40 -5
  139. package/src/lib/server/heartbeat-wake.ts +110 -0
  140. package/src/lib/server/langgraph-checkpoint.ts +1 -0
  141. package/src/lib/server/memory-consolidation.ts +92 -0
  142. package/src/lib/server/memory-db.ts +51 -6
  143. package/src/lib/server/openclaw-gateway.ts +9 -1
  144. package/src/lib/server/provider-health.ts +125 -0
  145. package/src/lib/server/queue.ts +5 -4
  146. package/src/lib/server/scheduler.ts +8 -0
  147. package/src/lib/server/session-run-manager.ts +4 -0
  148. package/src/lib/server/session-tools/chatroom.ts +136 -0
  149. package/src/lib/server/session-tools/context-mgmt.ts +36 -18
  150. package/src/lib/server/session-tools/index.ts +2 -0
  151. package/src/lib/server/session-tools/memory.ts +6 -1
  152. package/src/lib/server/storage.ts +53 -29
  153. package/src/lib/server/stream-agent-chat.ts +153 -47
  154. package/src/lib/server/system-events.ts +49 -0
  155. package/src/lib/server/ws-hub.ts +11 -0
  156. package/src/lib/soul-suggestions.ts +109 -0
  157. package/src/lib/tasks.ts +4 -1
  158. package/src/lib/view-routes.ts +36 -1
  159. package/src/lib/ws-client.ts +14 -4
  160. package/src/stores/use-app-store.ts +36 -2
  161. package/src/stores/use-chat-store.ts +48 -3
  162. package/src/stores/use-chatroom-store.ts +276 -0
  163. package/src/types/index.ts +56 -2
@@ -0,0 +1,156 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
4
+ import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
5
+ import { useAppStore } from '@/stores/use-app-store'
6
+ import { AgentAvatar } from '@/components/agents/agent-avatar'
7
+
8
+ export function AgentSwitchDialog() {
9
+ const [open, setOpen] = useState(false)
10
+ const [query, setQuery] = useState('')
11
+ const [selectedIdx, setSelectedIdx] = useState(0)
12
+ const inputRef = useRef<HTMLInputElement>(null)
13
+ const listRef = useRef<HTMLDivElement>(null)
14
+
15
+ const agents = useAppStore((s) => s.agents)
16
+ const currentAgentId = useAppStore((s) => s.currentAgentId)
17
+ const setCurrentAgent = useAppStore((s) => s.setCurrentAgent)
18
+
19
+ // Global Cmd+Shift+A / Ctrl+Shift+A listener
20
+ useEffect(() => {
21
+ const handler = (e: KeyboardEvent) => {
22
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'a') {
23
+ e.preventDefault()
24
+ setOpen((v) => !v)
25
+ }
26
+ }
27
+ window.addEventListener('keydown', handler)
28
+ return () => window.removeEventListener('keydown', handler)
29
+ }, [])
30
+
31
+ // Reset on open
32
+ useEffect(() => {
33
+ if (open) {
34
+ setQuery('')
35
+ setSelectedIdx(0)
36
+ setTimeout(() => inputRef.current?.focus(), 50)
37
+ }
38
+ }, [open])
39
+
40
+ const filtered = useMemo(() => {
41
+ const all = Object.values(agents).filter((a) => !a.trashedAt)
42
+ if (!query.trim()) return all
43
+ const q = query.toLowerCase()
44
+ return all.filter(
45
+ (a) => a.name.toLowerCase().includes(q) || (a.description || '').toLowerCase().includes(q),
46
+ )
47
+ }, [agents, query])
48
+
49
+ const handleSelect = useCallback((agentId: string) => {
50
+ setOpen(false)
51
+ void setCurrentAgent(agentId)
52
+ // eslint-disable-next-line react-hooks/exhaustive-deps
53
+ }, [])
54
+
55
+ const handleKeyDown = (e: React.KeyboardEvent) => {
56
+ if (e.key === 'ArrowDown') {
57
+ e.preventDefault()
58
+ setSelectedIdx((i) => Math.min(i + 1, filtered.length - 1))
59
+ } else if (e.key === 'ArrowUp') {
60
+ e.preventDefault()
61
+ setSelectedIdx((i) => Math.max(i - 1, 0))
62
+ } else if (e.key === 'Enter' && filtered[selectedIdx]) {
63
+ e.preventDefault()
64
+ handleSelect(filtered[selectedIdx].id)
65
+ }
66
+ }
67
+
68
+ // Scroll selected into view
69
+ useEffect(() => {
70
+ if (!listRef.current) return
71
+ const el = listRef.current.children[selectedIdx] as HTMLElement | undefined
72
+ el?.scrollIntoView({ block: 'nearest' })
73
+ }, [selectedIdx])
74
+
75
+ return (
76
+ <Dialog open={open} onOpenChange={setOpen}>
77
+ <DialogContent
78
+ showCloseButton={false}
79
+ className="sm:max-w-[440px] p-0 bg-[#1a1a2e]/95 backdrop-blur-xl border-white/[0.08] shadow-[0_24px_80px_rgba(0,0,0,0.6)] rounded-[16px] overflow-hidden gap-0"
80
+ onKeyDown={handleKeyDown}
81
+ >
82
+ <DialogTitle className="sr-only">Switch Agent</DialogTitle>
83
+ {/* Search input */}
84
+ <div className="flex items-center gap-3 px-4 py-3 border-b border-white/[0.06]">
85
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-text-3 shrink-0">
86
+ <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
87
+ <circle cx="12" cy="7" r="4" />
88
+ </svg>
89
+ <input
90
+ ref={inputRef}
91
+ value={query}
92
+ onChange={(e) => { setQuery(e.target.value); setSelectedIdx(0) }}
93
+ placeholder="Switch agent..."
94
+ className="flex-1 bg-transparent border-none outline-none text-[14px] text-text placeholder:text-text-3/60 font-[inherit]"
95
+ autoFocus
96
+ />
97
+ <kbd className="px-1.5 py-0.5 rounded-[5px] bg-white/[0.06] border border-white/[0.08] text-[10px] font-mono text-text-3 shrink-0">
98
+ ESC
99
+ </kbd>
100
+ </div>
101
+
102
+ {/* Agent list */}
103
+ <div ref={listRef} className="max-h-[360px] overflow-y-auto py-1">
104
+ {filtered.length === 0 && (
105
+ <div className="px-4 py-8 text-center text-[13px] text-text-3/60">
106
+ No agents found
107
+ </div>
108
+ )}
109
+ {filtered.map((agent, idx) => (
110
+ <button
111
+ key={agent.id}
112
+ onClick={() => handleSelect(agent.id)}
113
+ onMouseEnter={() => setSelectedIdx(idx)}
114
+ className={`w-full flex items-center gap-3 px-4 py-2.5 text-left cursor-pointer transition-colors border-none bg-transparent
115
+ ${idx === selectedIdx ? 'bg-white/[0.06]' : 'hover:bg-white/[0.04]'}`}
116
+ style={{ fontFamily: 'inherit' }}
117
+ >
118
+ <AgentAvatar seed={agent.avatarSeed} name={agent.name} size={28} />
119
+ <div className="flex-1 min-w-0">
120
+ <div className="flex items-center gap-2">
121
+ <span className="text-[13px] font-500 text-text truncate">{agent.name}</span>
122
+ {agent.id === currentAgentId && (
123
+ <span className="px-1.5 py-0.5 rounded-[4px] bg-accent-bright/15 text-[10px] font-500 text-accent-bright shrink-0">
124
+ current
125
+ </span>
126
+ )}
127
+ </div>
128
+ {agent.description && (
129
+ <p className="text-[11px] text-text-3 truncate mt-0.5 m-0">{agent.description}</p>
130
+ )}
131
+ </div>
132
+ </button>
133
+ ))}
134
+ </div>
135
+
136
+ {/* Footer hint */}
137
+ {filtered.length > 0 && (
138
+ <div className="flex items-center gap-3 px-4 py-2 border-t border-white/[0.06] text-[11px] text-text-3/50">
139
+ <span className="flex items-center gap-1">
140
+ <kbd className="px-1 py-0.5 rounded bg-white/[0.06] text-[10px] font-mono">↑↓</kbd>
141
+ navigate
142
+ </span>
143
+ <span className="flex items-center gap-1">
144
+ <kbd className="px-1 py-0.5 rounded bg-white/[0.06] text-[10px] font-mono">↵</kbd>
145
+ select
146
+ </span>
147
+ <span className="flex items-center gap-1">
148
+ <kbd className="px-1 py-0.5 rounded bg-white/[0.06] text-[10px] font-mono">esc</kbd>
149
+ close
150
+ </span>
151
+ </div>
152
+ )}
153
+ </DialogContent>
154
+ </Dialog>
155
+ )
156
+ }
@@ -0,0 +1,165 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { CodeBlock } from '@/components/chat/code-block'
5
+
6
+ export const IMAGE_ATTACH_RE = /\.(png|jpg|jpeg|gif|webp|svg|bmp|ico)$/i
7
+ export const PREVIEWABLE_ATTACH_RE = /\.(html?|svg)$/i
8
+ export const CODE_ATTACH_RE = /\.(js|jsx|ts|tsx|css|json|md|txt|py|sh|rb|go|rs|c|cpp|h|java|yaml|yml|toml|xml|sql|graphql)$/i
9
+ export const PDF_ATTACH_RE = /\.pdf$/i
10
+ export const FILE_TYPE_COLORS: Record<string, string> = {
11
+ html: 'text-orange-400', htm: 'text-orange-400', svg: 'text-emerald-400',
12
+ js: 'text-yellow-400', jsx: 'text-yellow-400', ts: 'text-blue-400', tsx: 'text-blue-400',
13
+ py: 'text-green-400', json: 'text-amber-300', css: 'text-purple-400', scss: 'text-pink-400',
14
+ md: 'text-text-2', txt: 'text-text-3', pdf: 'text-red-400',
15
+ }
16
+
17
+ export function parseAttachmentUrl(filePath?: string, fileUrl?: string) {
18
+ const url = fileUrl || (filePath ? `/api/uploads/${filePath.split('/').pop()}` : '')
19
+ const rawName = filePath?.split('/').pop() || fileUrl?.split('/').pop() || 'file'
20
+ const filename = rawName.replace(/^[a-f0-9]+-/, '').split('?')[0]
21
+ return { url, filename }
22
+ }
23
+
24
+ export function AttachmentChip({ url, filename, isUserMsg }: { url: string; filename: string; isUserMsg?: boolean }) {
25
+ const isImage = IMAGE_ATTACH_RE.test(filename)
26
+ const isCode = CODE_ATTACH_RE.test(filename)
27
+ const isPdf = PDF_ATTACH_RE.test(filename)
28
+ const [lightbox, setLightbox] = useState(false)
29
+ const [codePreview, setCodePreview] = useState<string | null>(null)
30
+ const [codeExpanded, setCodeExpanded] = useState(false)
31
+
32
+ if (isImage) {
33
+ return (
34
+ <>
35
+ <img
36
+ src={url} alt="Attached"
37
+ loading="lazy"
38
+ className="max-w-[240px] rounded-[12px] mb-2 border border-white/10 cursor-pointer hover:border-white/25 transition-colors"
39
+ onClick={() => setLightbox(true)}
40
+ onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
41
+ />
42
+ {lightbox && (
43
+ <div
44
+ className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm cursor-pointer"
45
+ onClick={() => setLightbox(false)}
46
+ >
47
+ <img src={url} alt="Preview" className="max-w-[90vw] max-h-[90vh] rounded-[12px] shadow-2xl" />
48
+ </div>
49
+ )}
50
+ </>
51
+ )
52
+ }
53
+
54
+ if (isPdf) {
55
+ return (
56
+ <div className="mb-2 rounded-[12px] border border-white/[0.08] bg-[rgba(255,255,255,0.02)] overflow-hidden" style={{ maxWidth: 480 }}>
57
+ <div className="flex items-center gap-3 px-4 py-2.5">
58
+ <div className="flex items-center justify-center w-8 h-8 rounded-[8px] shrink-0 bg-red-500/10 text-red-400">
59
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
60
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
61
+ <polyline points="14 2 14 8 20 8" />
62
+ </svg>
63
+ </div>
64
+ <span className="text-[13px] font-500 truncate flex-1">{filename}</span>
65
+ <a href={url} download={filename} className="text-[11px] font-600 text-text-3 hover:text-text-2 no-underline">Download</a>
66
+ </div>
67
+ <iframe src={url} loading="lazy" className="w-full h-[300px] border-t border-white/[0.06]" title={filename} />
68
+ </div>
69
+ )
70
+ }
71
+
72
+ const ext = filename.split('.').pop()?.toLowerCase() || ''
73
+ const colorClass = FILE_TYPE_COLORS[ext] || 'text-text-3'
74
+ const isPreviewable = PREVIEWABLE_ATTACH_RE.test(filename)
75
+
76
+ const chipBg = isUserMsg
77
+ ? 'bg-[rgba(0,0,0,0.25)] border-white/[0.12]'
78
+ : 'bg-[rgba(255,255,255,0.04)] border-white/[0.08]'
79
+ const iconBg = isUserMsg ? 'bg-white/[0.12]' : 'bg-white/[0.05]'
80
+ const btnBg = isUserMsg
81
+ ? 'bg-white/[0.12] hover:bg-white/[0.18] text-white/80'
82
+ : 'bg-white/[0.06] hover:bg-white/[0.10] text-text-3'
83
+
84
+ const handleCodePreview = async () => {
85
+ if (codePreview !== null) { setCodeExpanded(!codeExpanded); return }
86
+ try {
87
+ const serveUrl = `/api/files/serve?path=${encodeURIComponent(url.replace('/api/uploads/', ''))}`
88
+ const res = await fetch(url.startsWith('/api/files/') ? url : serveUrl)
89
+ if (!res.ok) return
90
+ const text = await res.text()
91
+ setCodePreview(text)
92
+ setCodeExpanded(true)
93
+ } catch {
94
+ // ignore
95
+ }
96
+ }
97
+
98
+ return (
99
+ <div className="mb-2">
100
+ <div className={`flex items-center gap-3 px-4 py-2.5 rounded-[12px] border ${chipBg}`}>
101
+ <div className={`flex items-center justify-center w-8 h-8 rounded-[8px] shrink-0 ${iconBg} ${colorClass}`}>
102
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
103
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
104
+ <polyline points="14 2 14 8 20 8" />
105
+ </svg>
106
+ </div>
107
+ <div className="flex flex-col flex-1 min-w-0">
108
+ <span className={`text-[13px] font-500 truncate ${isUserMsg ? 'text-white' : 'text-text'}`}>{filename}</span>
109
+ <span className={`text-[11px] uppercase tracking-wide ${isUserMsg ? 'text-white/50' : 'text-text-3/70'}`}>{ext || 'file'}</span>
110
+ </div>
111
+ {isCode && (
112
+ <button
113
+ onClick={handleCodePreview}
114
+ className={`flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] text-[11px] font-600 no-underline transition-colors shrink-0 border-none cursor-pointer ${
115
+ isUserMsg ? 'bg-white/[0.15] hover:bg-white/[0.22] text-white' : 'bg-accent-soft hover:bg-accent-soft/80 text-accent-bright'
116
+ }`}
117
+ >
118
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
119
+ <polyline points="16 18 22 12 16 6" />
120
+ <polyline points="8 6 2 12 8 18" />
121
+ </svg>
122
+ {codeExpanded ? 'Hide' : 'Preview'}
123
+ </button>
124
+ )}
125
+ {isPreviewable && (
126
+ <a href={url} target="_blank" rel="noopener noreferrer"
127
+ className={`flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] text-[11px] font-600 no-underline transition-colors shrink-0 ${
128
+ isUserMsg ? 'bg-white/[0.15] hover:bg-white/[0.22] text-white' : 'bg-accent-soft hover:bg-accent-soft/80 text-accent-bright'
129
+ }`}
130
+ title="Preview in new tab">
131
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
132
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
133
+ <circle cx="12" cy="12" r="3" />
134
+ </svg>
135
+ Preview
136
+ </a>
137
+ )}
138
+ <a href={url} download={filename}
139
+ className={`flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] text-[11px] font-600 no-underline transition-colors shrink-0 ${btnBg}`}>
140
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
141
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
142
+ <polyline points="7 10 12 15 17 10" />
143
+ <line x1="12" y1="15" x2="12" y2="3" />
144
+ </svg>
145
+ Download
146
+ </a>
147
+ </div>
148
+ {isCode && codeExpanded && codePreview !== null && (
149
+ <div className="mt-1 rounded-[10px] border border-white/[0.06] overflow-hidden" style={{ animation: 'fade-in 0.2s ease' }}>
150
+ <CodeBlock className={`language-${ext}`}>
151
+ {codePreview.split('\n').slice(0, codeExpanded ? undefined : 10).join('\n')}
152
+ </CodeBlock>
153
+ {codePreview.split('\n').length > 10 && (
154
+ <button
155
+ onClick={() => setCodeExpanded((v) => !v)}
156
+ className="w-full px-3 py-1.5 text-[10px] text-text-3 hover:text-text-2 bg-white/[0.02] hover:bg-white/[0.04] border-none border-t border-white/[0.06] cursor-pointer transition-colors"
157
+ >
158
+ {codePreview.split('\n').length > 10 ? `Show all ${codePreview.split('\n').length} lines` : 'Show less'}
159
+ </button>
160
+ )}
161
+ </div>
162
+ )}
163
+ </div>
164
+ )
165
+ }
@@ -1,8 +1,11 @@
1
1
  'use client'
2
2
 
3
+ import { AgentAvatar } from '@/components/agents/agent-avatar'
4
+
3
5
  interface Props {
4
6
  user: string
5
7
  size?: 'xs' | 'sm' | 'md' | 'lg'
8
+ avatarSeed?: string
6
9
  }
7
10
 
8
11
  const sizes = {
@@ -22,7 +25,13 @@ function userGradient(name: string): string {
22
25
  return `linear-gradient(135deg, hsl(${hue}, 70%, 35%), hsl(${(hue + 30) % 360}, 75%, 50%))`
23
26
  }
24
27
 
25
- export function Avatar({ user, size = 'md' }: Props) {
28
+ const pixelSizes: Record<string, number> = { xs: 24, sm: 28, md: 36, lg: 72 }
29
+
30
+ export function Avatar({ user, size = 'md', avatarSeed }: Props) {
31
+ if (avatarSeed) {
32
+ return <AgentAvatar seed={avatarSeed} name={user} size={pixelSizes[size] || 36} />
33
+ }
34
+
26
35
  const initial = (user || '?')[0].toUpperCase()
27
36
  return (
28
37
  <div
@@ -0,0 +1,12 @@
1
+ interface Props {
2
+ size?: number
3
+ className?: string
4
+ }
5
+
6
+ export function CheckIcon({ size = 14, className }: Props) {
7
+ return (
8
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className={className}>
9
+ <polyline points="20 6 9 17 4 12" />
10
+ </svg>
11
+ )
12
+ }
@@ -35,7 +35,7 @@ export function ConfirmDialog({ open, title, message, confirmLabel = 'Confirm',
35
35
  className={`flex-1 py-2.5 rounded-[12px] border-none text-[13px] font-600 cursor-pointer active:scale-[0.97] transition-all duration-200
36
36
  ${danger
37
37
  ? 'bg-danger text-white shadow-[0_4px_20px_rgba(244,63,94,0.2)]'
38
- : 'bg-[#6366F1] text-white shadow-[0_4px_20px_rgba(99,102,241,0.2)]'}`}
38
+ : 'bg-accent-bright text-white shadow-[0_4px_20px_rgba(99,102,241,0.2)]'}`}
39
39
  style={{ fontFamily: 'inherit' }}
40
40
  >
41
41
  {confirmLabel}
@@ -0,0 +1,32 @@
1
+ interface Props {
2
+ icon: React.ReactNode
3
+ title: string
4
+ subtitle?: string
5
+ action?: {
6
+ label: string
7
+ onClick: () => void
8
+ }
9
+ }
10
+
11
+ export function EmptyState({ icon, title, subtitle, action }: Props) {
12
+ return (
13
+ <div className="flex-1 flex flex-col items-center justify-center gap-4 text-text-3 p-8 text-center">
14
+ <div className="w-12 h-12 rounded-[14px] bg-accent-soft flex items-center justify-center mb-1">
15
+ {icon}
16
+ </div>
17
+ <p className="font-display text-[15px] font-600 text-text-2">{title}</p>
18
+ {subtitle && <p className="text-[13px] text-text-3/50">{subtitle}</p>}
19
+ {action && (
20
+ <button
21
+ onClick={action.onClick}
22
+ className="mt-3 px-8 py-3 rounded-[14px] border-none bg-accent-bright text-white
23
+ text-[14px] font-600 cursor-pointer active:scale-95 transition-all duration-200
24
+ shadow-[0_4px_16px_rgba(99,102,241,0.2)]"
25
+ style={{ fontFamily: 'inherit' }}
26
+ >
27
+ {action.label}
28
+ </button>
29
+ )}
30
+ </div>
31
+ )
32
+ }
@@ -0,0 +1,34 @@
1
+ 'use client'
2
+
3
+ import type { PendingFile } from '@/stores/use-chat-store'
4
+
5
+ export function FilePreview({ file, onRemove }: { file: PendingFile; onRemove: () => void }) {
6
+ const isImage = file.file.type.startsWith('image/')
7
+ return (
8
+ <div className="relative">
9
+ {isImage ? (
10
+ <img
11
+ src={URL.createObjectURL(file.file)}
12
+ alt="Preview"
13
+ className="h-16 rounded-[10px] object-cover border border-white/[0.06]"
14
+ />
15
+ ) : (
16
+ <div className="flex items-center gap-2.5 px-3 py-2.5 rounded-[10px] border border-white/[0.06] bg-white/[0.03]">
17
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="text-text-3 shrink-0">
18
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
19
+ <polyline points="14 2 14 8 20 8" />
20
+ </svg>
21
+ <span className="text-[13px] text-text-2 font-500 truncate max-w-[180px]">{file.file.name}</span>
22
+ </div>
23
+ )}
24
+ <button
25
+ onClick={onRemove}
26
+ className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full border border-white/10 bg-raised
27
+ text-text-2 text-[10px] cursor-pointer flex items-center justify-center
28
+ hover:bg-danger-soft hover:text-danger hover:border-danger/20 transition-colors"
29
+ >
30
+ &times;
31
+ </button>
32
+ </div>
33
+ )
34
+ }
@@ -0,0 +1,2 @@
1
+ /** Standard input class for sheet/form inputs */
2
+ export const inputClass = "w-full px-4 py-3.5 rounded-[14px] border border-white/[0.08] bg-surface text-text text-[15px] outline-none transition-all duration-200 placeholder:text-text-3/50 focus-glow"
@@ -0,0 +1,116 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect } from 'react'
4
+ import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
5
+
6
+ interface Shortcut {
7
+ keys: string[]
8
+ description: string
9
+ }
10
+
11
+ interface ShortcutGroup {
12
+ title: string
13
+ shortcuts: Shortcut[]
14
+ }
15
+
16
+ const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent)
17
+ const MOD = isMac ? '\u2318' : 'Ctrl'
18
+
19
+ const GROUPS: ShortcutGroup[] = [
20
+ {
21
+ title: 'Navigation',
22
+ shortcuts: [
23
+ { keys: [MOD, 'K'], description: 'Open search' },
24
+ { keys: [MOD, 'Shift', 'A'], description: 'Switch agent' },
25
+ { keys: [MOD, 'N'], description: 'New chat' },
26
+ { keys: [MOD, 'Shift', 'T'], description: 'Jump to tasks' },
27
+ ],
28
+ },
29
+ {
30
+ title: 'Chat',
31
+ shortcuts: [
32
+ { keys: ['Enter'], description: 'Send message' },
33
+ { keys: ['Shift', 'Enter'], description: 'New line' },
34
+ { keys: ['Esc'], description: 'Cancel reply / close' },
35
+ ],
36
+ },
37
+ {
38
+ title: 'General',
39
+ shortcuts: [
40
+ { keys: ['?'], description: 'Show keyboard shortcuts' },
41
+ ],
42
+ },
43
+ ]
44
+
45
+ function Kbd({ children }: { children: string }) {
46
+ return (
47
+ <kbd className="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-[5px] bg-white/[0.08] border border-white/[0.1] text-[11px] font-mono text-text-2 leading-none">
48
+ {children}
49
+ </kbd>
50
+ )
51
+ }
52
+
53
+ export function KeyboardShortcutsDialog() {
54
+ const [open, setOpen] = useState(false)
55
+
56
+ useEffect(() => {
57
+ const handler = (e: KeyboardEvent) => {
58
+ // Ctrl+/ or Cmd+/
59
+ if ((e.metaKey || e.ctrlKey) && e.key === '/') {
60
+ e.preventDefault()
61
+ setOpen((v) => !v)
62
+ return
63
+ }
64
+ const tag = (e.target as HTMLElement)?.tagName
65
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
66
+ if ((e.target as HTMLElement)?.isContentEditable) return
67
+ if (e.key === '?' && !e.metaKey && !e.ctrlKey && !e.altKey) {
68
+ e.preventDefault()
69
+ setOpen((v) => !v)
70
+ }
71
+ }
72
+ window.addEventListener('keydown', handler)
73
+ return () => window.removeEventListener('keydown', handler)
74
+ }, [])
75
+
76
+ return (
77
+ <Dialog open={open} onOpenChange={setOpen}>
78
+ <DialogContent
79
+ showCloseButton={false}
80
+ className="sm:max-w-[420px] p-0 bg-[#1a1a2e]/95 backdrop-blur-xl border-white/[0.08] shadow-[0_24px_80px_rgba(0,0,0,0.6)] rounded-[16px] overflow-hidden gap-0"
81
+ >
82
+ <DialogTitle className="sr-only">Keyboard shortcuts</DialogTitle>
83
+ <div className="flex items-center justify-between px-5 py-3.5 border-b border-white/[0.06]">
84
+ <span className="text-[14px] font-600 text-text">Keyboard Shortcuts</span>
85
+ <kbd className="px-1.5 py-0.5 rounded-[5px] bg-white/[0.06] border border-white/[0.08] text-[10px] font-mono text-text-3">
86
+ ESC
87
+ </kbd>
88
+ </div>
89
+ <div className="py-2 max-h-[400px] overflow-y-auto">
90
+ {GROUPS.map((group) => (
91
+ <div key={group.title} className="px-5 py-2">
92
+ <h3 className="text-[11px] font-700 uppercase tracking-wider text-text-3/60 mb-2">
93
+ {group.title}
94
+ </h3>
95
+ <div className="flex flex-col gap-1.5">
96
+ {group.shortcuts.map((shortcut) => (
97
+ <div
98
+ key={shortcut.description}
99
+ className="flex items-center justify-between py-1"
100
+ >
101
+ <span className="text-[13px] text-text-2">{shortcut.description}</span>
102
+ <div className="flex items-center gap-1">
103
+ {shortcut.keys.map((key, i) => (
104
+ <Kbd key={i}>{key}</Kbd>
105
+ ))}
106
+ </div>
107
+ </div>
108
+ ))}
109
+ </div>
110
+ </div>
111
+ ))}
112
+ </div>
113
+ </DialogContent>
114
+ </Dialog>
115
+ )
116
+ }
@@ -34,7 +34,25 @@ const TYPE_ICON_COLORS: Record<AppNotification['type'], string> = {
34
34
  error: 'text-red-400',
35
35
  }
36
36
 
37
- export function NotificationCenter() {
37
+ function resolveHttpUrl(raw: string | undefined): string | null {
38
+ if (!raw) return null
39
+ try {
40
+ const parsed = new URL(raw)
41
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.toString() : null
42
+ } catch {
43
+ return null
44
+ }
45
+ }
46
+
47
+ export function NotificationCenter({
48
+ variant = 'icon',
49
+ align = 'right',
50
+ direction = 'down',
51
+ }: {
52
+ variant?: 'icon' | 'row'
53
+ align?: 'left' | 'right'
54
+ direction?: 'up' | 'down'
55
+ }) {
38
56
  const [open, setOpen] = useState(false)
39
57
  const panelRef = useRef<HTMLDivElement>(null)
40
58
  const buttonRef = useRef<HTMLButtonElement>(null)
@@ -76,27 +94,42 @@ export function NotificationCenter() {
76
94
  if (!n.read) {
77
95
  markRead(n.id)
78
96
  }
79
- // If there's an entity, we could navigate - for now just mark as read
97
+ const actionUrl = resolveHttpUrl(n.actionUrl)
98
+ if (actionUrl) {
99
+ window.open(actionUrl, '_blank', 'noopener,noreferrer')
100
+ }
80
101
  setOpen(false)
81
102
  }
82
103
 
104
+ const isRow = variant === 'row'
105
+ const panelAlignClass = align === 'left' ? 'left-0' : 'right-0'
106
+ const panelDirectionClass = direction === 'up' ? 'bottom-full mb-2' : 'top-full mt-2'
107
+
83
108
  return (
84
109
  <div className="relative">
85
110
  <button
86
111
  ref={buttonRef}
87
112
  onClick={() => setOpen((v) => !v)}
88
- className="relative flex items-center justify-center w-8 h-8 rounded-[8px] bg-transparent hover:bg-white/[0.05] transition-colors cursor-pointer border-none"
113
+ className={
114
+ isRow
115
+ ? 'relative w-full flex items-center gap-2.5 px-3 py-2 rounded-[10px] text-[13px] font-500 cursor-pointer transition-all bg-transparent text-text-3 hover:text-text hover:bg-white/[0.04] border-none'
116
+ : 'relative flex items-center justify-center w-8 h-8 rounded-[8px] bg-transparent hover:bg-white/[0.05] transition-colors cursor-pointer border-none'
117
+ }
89
118
  aria-label="Notifications"
90
119
  title={unreadCount > 0 ? `${unreadCount} unread notifications` : 'Notifications'}
91
120
  >
92
121
  {/* Bell icon */}
93
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-text-2">
122
+ <svg width={isRow ? '16' : '16'} height={isRow ? '16' : '16'} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={isRow ? 'text-text-3 shrink-0' : 'text-text-2'}>
94
123
  <path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
95
124
  <path d="M13.73 21a2 2 0 0 1-3.46 0" />
96
125
  </svg>
126
+ {isRow && <span className="text-[13px] font-500">Notifications</span>}
97
127
  {/* Badge */}
98
128
  {unreadCount > 0 && (
99
- <span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 flex items-center justify-center rounded-full bg-red-500 text-[10px] font-700 text-white px-1 leading-none">
129
+ <span className={isRow
130
+ ? 'ml-auto min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-red-500 text-[10px] font-700 text-white px-1 leading-none'
131
+ : 'absolute -top-0.5 -right-0.5 min-w-[16px] h-4 flex items-center justify-center rounded-full bg-red-500 text-[10px] font-700 text-white px-1 leading-none'}
132
+ >
100
133
  {unreadCount > 99 ? '99+' : unreadCount}
101
134
  </span>
102
135
  )}
@@ -105,7 +138,7 @@ export function NotificationCenter() {
105
138
  {open && (
106
139
  <div
107
140
  ref={panelRef}
108
- className="absolute right-0 top-full mt-2 w-[340px] max-h-[460px] bg-raised border border-white/[0.06] rounded-[14px] shadow-[0_16px_64px_rgba(0,0,0,0.6)] backdrop-blur-xl z-90 flex flex-col overflow-hidden"
141
+ className={`absolute ${panelAlignClass} ${panelDirectionClass} w-[340px] max-h-[460px] bg-raised border border-white/[0.06] rounded-[14px] shadow-[0_16px_64px_rgba(0,0,0,0.6)] backdrop-blur-xl z-90 flex flex-col overflow-hidden`}
109
142
  style={{ animation: 'fade-in 0.15s cubic-bezier(0.16, 1, 0.3, 1)' }}
110
143
  >
111
144
  {/* Header */}
@@ -164,6 +197,11 @@ export function NotificationCenter() {
164
197
  {n.message}
165
198
  </p>
166
199
  )}
200
+ {resolveHttpUrl(n.actionUrl) && (
201
+ <span className="inline-block mt-1 text-[11px] text-accent-bright/90">
202
+ {n.actionLabel || 'Open link'}
203
+ </span>
204
+ )}
167
205
  {n.entityType && (
168
206
  <span className="inline-block mt-1 text-[10px] text-text-3/40 font-mono">
169
207
  {n.entityType}{n.entityId ? `:${n.entityId.slice(0, 8)}` : ''}