@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.
- package/README.md +39 -8
- package/bin/swarmclaw.js +76 -16
- package/next.config.ts +11 -1
- package/package.json +4 -2
- package/scripts/postinstall.mjs +18 -0
- package/src/app/api/chatrooms/[id]/chat/route.ts +410 -0
- package/src/app/api/chatrooms/[id]/members/route.ts +82 -0
- package/src/app/api/chatrooms/[id]/pins/route.ts +39 -0
- package/src/app/api/chatrooms/[id]/reactions/route.ts +42 -0
- package/src/app/api/chatrooms/[id]/route.ts +84 -0
- package/src/app/api/chatrooms/route.ts +50 -0
- package/src/app/api/credentials/route.ts +2 -3
- package/src/app/api/knowledge/[id]/route.ts +13 -2
- package/src/app/api/knowledge/route.ts +8 -1
- package/src/app/api/memory/route.ts +8 -0
- package/src/app/api/notifications/route.ts +4 -0
- package/src/app/api/orchestrator/run/route.ts +1 -1
- package/src/app/api/plugins/install/route.ts +2 -2
- package/src/app/api/search/route.ts +51 -1
- package/src/app/api/sessions/[id]/chat/route.ts +2 -0
- package/src/app/api/sessions/[id]/edit-resend/route.ts +1 -1
- package/src/app/api/sessions/[id]/fork/route.ts +1 -1
- package/src/app/api/sessions/route.ts +3 -3
- package/src/app/api/settings/route.ts +9 -0
- package/src/app/api/setup/check-provider/route.ts +3 -16
- package/src/app/api/skills/[id]/route.ts +6 -0
- package/src/app/api/skills/route.ts +6 -0
- package/src/app/api/tasks/[id]/route.ts +12 -0
- package/src/app/api/tasks/bulk/route.ts +100 -0
- package/src/app/api/tasks/route.ts +1 -0
- package/src/app/api/webhooks/[id]/route.ts +15 -1
- package/src/app/globals.css +58 -15
- package/src/app/page.tsx +142 -13
- package/src/cli/index.js +24 -0
- package/src/cli/index.test.js +30 -0
- package/src/cli/spec.js +16 -0
- package/src/components/agents/agent-avatar.tsx +57 -10
- package/src/components/agents/agent-card.tsx +48 -15
- package/src/components/agents/agent-chat-list.tsx +123 -10
- package/src/components/agents/agent-list.tsx +50 -19
- package/src/components/agents/agent-sheet.tsx +56 -63
- package/src/components/auth/access-key-gate.tsx +10 -3
- package/src/components/auth/setup-wizard.tsx +2 -2
- package/src/components/auth/user-picker.tsx +31 -3
- package/src/components/chat/activity-moment.tsx +169 -0
- package/src/components/chat/chat-header.tsx +2 -0
- package/src/components/chat/chat-tool-toggles.tsx +1 -1
- package/src/components/chat/file-path-chip.tsx +125 -0
- package/src/components/chat/markdown-utils.ts +9 -0
- package/src/components/chat/message-bubble.tsx +46 -295
- package/src/components/chat/message-list.tsx +50 -1
- package/src/components/chat/streaming-bubble.tsx +36 -46
- package/src/components/chat/suggestions-bar.tsx +1 -1
- package/src/components/chat/thinking-indicator.tsx +72 -10
- package/src/components/chat/tool-call-bubble.tsx +66 -70
- package/src/components/chat/tool-request-banner.tsx +31 -7
- package/src/components/chat/transfer-agent-picker.tsx +63 -0
- package/src/components/chatrooms/agent-hover-card.tsx +124 -0
- package/src/components/chatrooms/chatroom-input.tsx +320 -0
- package/src/components/chatrooms/chatroom-list.tsx +123 -0
- package/src/components/chatrooms/chatroom-message.tsx +427 -0
- package/src/components/chatrooms/chatroom-sheet.tsx +215 -0
- package/src/components/chatrooms/chatroom-tool-request-banner.tsx +134 -0
- package/src/components/chatrooms/chatroom-typing-bar.tsx +88 -0
- package/src/components/chatrooms/chatroom-view.tsx +344 -0
- package/src/components/chatrooms/reaction-picker.tsx +273 -0
- package/src/components/connectors/connector-sheet.tsx +34 -47
- package/src/components/home/home-view.tsx +501 -0
- package/src/components/input/chat-input.tsx +79 -41
- package/src/components/knowledge/knowledge-list.tsx +31 -1
- package/src/components/knowledge/knowledge-sheet.tsx +83 -2
- package/src/components/layout/app-layout.tsx +175 -95
- package/src/components/layout/update-banner.tsx +2 -2
- package/src/components/logs/log-list.tsx +2 -2
- package/src/components/mcp-servers/mcp-server-sheet.tsx +1 -1
- package/src/components/memory/memory-agent-list.tsx +143 -0
- package/src/components/memory/memory-browser.tsx +205 -0
- package/src/components/memory/memory-card.tsx +34 -7
- package/src/components/memory/memory-detail.tsx +359 -120
- package/src/components/memory/memory-sheet.tsx +157 -23
- package/src/components/plugins/plugin-list.tsx +1 -1
- package/src/components/plugins/plugin-sheet.tsx +1 -1
- package/src/components/projects/project-detail.tsx +509 -0
- package/src/components/projects/project-list.tsx +195 -59
- package/src/components/providers/provider-list.tsx +2 -2
- package/src/components/providers/provider-sheet.tsx +3 -3
- package/src/components/schedules/schedule-card.tsx +1 -1
- package/src/components/schedules/schedule-list.tsx +1 -1
- package/src/components/schedules/schedule-sheet.tsx +25 -25
- package/src/components/secrets/secret-sheet.tsx +47 -24
- package/src/components/secrets/secrets-list.tsx +18 -8
- package/src/components/sessions/new-session-sheet.tsx +33 -65
- package/src/components/sessions/session-card.tsx +45 -14
- package/src/components/sessions/session-list.tsx +35 -18
- package/src/components/shared/agent-picker-list.tsx +90 -0
- package/src/components/shared/agent-switch-dialog.tsx +156 -0
- package/src/components/shared/attachment-chip.tsx +165 -0
- package/src/components/shared/avatar.tsx +10 -1
- package/src/components/shared/check-icon.tsx +12 -0
- package/src/components/shared/confirm-dialog.tsx +1 -1
- package/src/components/shared/empty-state.tsx +32 -0
- package/src/components/shared/file-preview.tsx +34 -0
- package/src/components/shared/form-styles.ts +2 -0
- package/src/components/shared/keyboard-shortcuts-dialog.tsx +116 -0
- package/src/components/shared/notification-center.tsx +44 -6
- package/src/components/shared/profile-sheet.tsx +115 -0
- package/src/components/shared/reply-quote.tsx +26 -0
- package/src/components/shared/search-dialog.tsx +14 -5
- package/src/components/shared/section-label.tsx +12 -0
- package/src/components/shared/settings/plugin-manager.tsx +1 -1
- package/src/components/shared/settings/section-providers.tsx +1 -1
- package/src/components/shared/settings/section-secrets.tsx +1 -1
- package/src/components/shared/settings/section-theme.tsx +95 -0
- package/src/components/shared/settings/section-user-preferences.tsx +39 -0
- package/src/components/shared/settings/settings-page.tsx +180 -27
- package/src/components/shared/settings/settings-sheet.tsx +9 -73
- package/src/components/shared/sheet-footer.tsx +33 -0
- package/src/components/skills/skill-list.tsx +61 -30
- package/src/components/skills/skill-sheet.tsx +81 -2
- package/src/components/tasks/task-board.tsx +448 -26
- package/src/components/tasks/task-card.tsx +46 -9
- package/src/components/tasks/task-column.tsx +62 -3
- package/src/components/tasks/task-list.tsx +12 -4
- package/src/components/tasks/task-sheet.tsx +89 -72
- package/src/components/ui/hover-card.tsx +52 -0
- package/src/components/usage/usage-list.tsx +1 -1
- package/src/components/webhooks/webhook-sheet.tsx +1 -1
- package/src/hooks/use-view-router.ts +69 -19
- package/src/instrumentation.ts +15 -1
- package/src/lib/chat.ts +2 -0
- package/src/lib/memory.ts +3 -0
- package/src/lib/server/chat-execution.ts +24 -4
- package/src/lib/server/connectors/manager.ts +11 -0
- package/src/lib/server/context-manager.ts +225 -13
- package/src/lib/server/create-notification.ts +14 -2
- package/src/lib/server/daemon-state.ts +157 -10
- package/src/lib/server/execution-log.ts +1 -0
- package/src/lib/server/heartbeat-service.ts +40 -5
- package/src/lib/server/heartbeat-wake.ts +110 -0
- package/src/lib/server/langgraph-checkpoint.ts +1 -0
- package/src/lib/server/memory-consolidation.ts +92 -0
- package/src/lib/server/memory-db.ts +51 -6
- package/src/lib/server/openclaw-gateway.ts +9 -1
- package/src/lib/server/provider-health.ts +125 -0
- package/src/lib/server/queue.ts +5 -4
- package/src/lib/server/scheduler.ts +8 -0
- package/src/lib/server/session-run-manager.ts +4 -0
- package/src/lib/server/session-tools/chatroom.ts +136 -0
- package/src/lib/server/session-tools/context-mgmt.ts +36 -18
- package/src/lib/server/session-tools/index.ts +2 -0
- package/src/lib/server/session-tools/memory.ts +6 -1
- package/src/lib/server/storage.ts +53 -29
- package/src/lib/server/stream-agent-chat.ts +153 -47
- package/src/lib/server/system-events.ts +49 -0
- package/src/lib/server/ws-hub.ts +11 -0
- package/src/lib/soul-suggestions.ts +109 -0
- package/src/lib/tasks.ts +4 -1
- package/src/lib/view-routes.ts +36 -1
- package/src/lib/ws-client.ts +14 -4
- package/src/stores/use-app-store.ts +36 -2
- package/src/stores/use-chat-store.ts +48 -3
- package/src/stores/use-chatroom-store.ts +276 -0
- 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
|
-
|
|
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-
|
|
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
|
+
×
|
|
31
|
+
</button>
|
|
32
|
+
</div>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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)}` : ''}
|