@swarmclawai/swarmclaw 0.5.2 → 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 +42 -7
- package/bin/swarmclaw.js +76 -16
- package/next.config.ts +11 -1
- package/package.json +4 -2
- package/public/screenshots/agents.png +0 -0
- package/public/screenshots/dashboard.png +0 -0
- package/public/screenshots/providers.png +0 -0
- package/public/screenshots/tasks.png +0 -0
- package/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/[id]/route.ts +27 -0
- package/src/app/api/notifications/route.ts +68 -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 +155 -0
- 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 +20 -0
- package/src/app/api/tasks/bulk/route.ts +100 -0
- package/src/app/api/tasks/route.ts +1 -0
- package/src/app/api/usage/route.ts +45 -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 +42 -0
- package/src/cli/index.test.js +30 -0
- package/src/cli/spec.js +32 -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 +209 -83
- package/src/components/layout/mobile-header.tsx +2 -0
- 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 +3 -2
- 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 +223 -0
- 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 +296 -0
- 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/metrics-dashboard.tsx +78 -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/cron-human.ts +114 -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 +42 -0
- package/src/lib/server/daemon-state.ts +165 -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 +80 -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/proxy.ts +79 -2
- package/src/stores/use-app-store.ts +94 -3
- package/src/stores/use-chat-store.ts +48 -3
- package/src/stores/use-chatroom-store.ts +276 -0
- package/src/types/index.ts +69 -2
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { AgentAvatar } from '@/components/agents/agent-avatar'
|
|
4
|
+
import { CheckIcon } from '@/components/shared/check-icon'
|
|
5
|
+
import type { Agent } from '@/types'
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
agents: Agent[]
|
|
9
|
+
/** Currently selected agent ID(s). String for single-select, string[] for multi-select. */
|
|
10
|
+
selected: string | string[]
|
|
11
|
+
/** Called when an agent is clicked. In multi mode, caller should toggle; in single mode, set. */
|
|
12
|
+
onSelect: (agentId: string) => void
|
|
13
|
+
/** Show a "None" option at the top for optional single-select */
|
|
14
|
+
noneOption?: { label: string; onSelect: () => void }
|
|
15
|
+
/** Show orchestrator badge */
|
|
16
|
+
showOrchBadge?: boolean
|
|
17
|
+
/** Max height of the scrollable list */
|
|
18
|
+
maxHeight?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function AgentPickerList({
|
|
22
|
+
agents,
|
|
23
|
+
selected,
|
|
24
|
+
onSelect,
|
|
25
|
+
noneOption,
|
|
26
|
+
showOrchBadge,
|
|
27
|
+
maxHeight = 220,
|
|
28
|
+
}: Props) {
|
|
29
|
+
const isSelected = (id: string) =>
|
|
30
|
+
Array.isArray(selected) ? selected.includes(id) : selected === id
|
|
31
|
+
const noneSelected = Array.isArray(selected) ? selected.length === 0 : !selected
|
|
32
|
+
|
|
33
|
+
if (agents.length === 0 && !noneOption) {
|
|
34
|
+
return <p className="text-[13px] text-text-3">No agents configured.</p>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
className="flex flex-col gap-1 rounded-[14px] border border-white/[0.06] bg-surface p-1.5 overflow-y-auto"
|
|
40
|
+
style={{ maxHeight }}
|
|
41
|
+
>
|
|
42
|
+
{noneOption && (
|
|
43
|
+
<button
|
|
44
|
+
onClick={noneOption.onSelect}
|
|
45
|
+
className={`relative flex items-center gap-3 px-3 py-2.5 rounded-[10px] cursor-pointer transition-all w-full text-left border-none
|
|
46
|
+
${noneSelected ? 'bg-accent-soft' : 'bg-transparent hover:bg-white/[0.03]'}`}
|
|
47
|
+
style={{ fontFamily: 'inherit' }}
|
|
48
|
+
>
|
|
49
|
+
{noneSelected && (
|
|
50
|
+
<div className="absolute left-0 top-2 bottom-2 w-[2.5px] rounded-full bg-accent-bright" />
|
|
51
|
+
)}
|
|
52
|
+
<div className="w-[28px] h-[28px] rounded-full bg-white/[0.06] flex items-center justify-center shrink-0">
|
|
53
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className={noneSelected ? 'text-accent-bright' : 'text-text-3'}>
|
|
54
|
+
<circle cx="12" cy="12" r="10" /><line x1="8" y1="12" x2="16" y2="12" />
|
|
55
|
+
</svg>
|
|
56
|
+
</div>
|
|
57
|
+
<span className={`text-[13px] font-600 flex-1 ${noneSelected ? 'text-accent-bright' : 'text-text-2'}`}>
|
|
58
|
+
{noneOption.label}
|
|
59
|
+
</span>
|
|
60
|
+
</button>
|
|
61
|
+
)}
|
|
62
|
+
{agents.map((a) => {
|
|
63
|
+
const active = isSelected(a.id)
|
|
64
|
+
return (
|
|
65
|
+
<button
|
|
66
|
+
key={a.id}
|
|
67
|
+
onClick={() => onSelect(a.id)}
|
|
68
|
+
className={`relative flex items-center gap-3 px-3 py-2.5 rounded-[10px] cursor-pointer transition-all w-full text-left border-none
|
|
69
|
+
${active ? 'bg-accent-soft' : 'bg-transparent hover:bg-white/[0.03]'}`}
|
|
70
|
+
style={{ fontFamily: 'inherit' }}
|
|
71
|
+
>
|
|
72
|
+
{active && (
|
|
73
|
+
<div className="absolute left-0 top-2 bottom-2 w-[2.5px] rounded-full bg-accent-bright" />
|
|
74
|
+
)}
|
|
75
|
+
<AgentAvatar seed={a.avatarSeed || null} name={a.name} size={28} />
|
|
76
|
+
<span className={`text-[13px] font-600 flex-1 truncate ${active ? 'text-accent-bright' : 'text-text-2'}`}>
|
|
77
|
+
{a.name}
|
|
78
|
+
</span>
|
|
79
|
+
{showOrchBadge && a.isOrchestrator && (
|
|
80
|
+
<span className="text-[10px] text-text-3/60 flex items-center gap-0.5">
|
|
81
|
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M16 3h5v5"/><path d="M21 3l-7 7"/><path d="M8 21H3v-5"/><path d="M3 21l7-7"/></svg>
|
|
82
|
+
</span>
|
|
83
|
+
)}
|
|
84
|
+
{active && <CheckIcon className="text-accent-bright shrink-0" />}
|
|
85
|
+
</button>
|
|
86
|
+
)
|
|
87
|
+
})}
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
@@ -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
|
+
}
|