@swarmclawai/swarmclaw 0.3.0 → 0.4.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 (118) hide show
  1. package/README.md +20 -11
  2. package/bin/server-cmd.js +14 -7
  3. package/bin/swarmclaw.js +3 -1
  4. package/bin/update-cmd.js +120 -0
  5. package/next.config.ts +2 -0
  6. package/package.json +3 -1
  7. package/src/app/api/agents/[id]/route.ts +3 -0
  8. package/src/app/api/agents/[id]/thread/route.ts +2 -1
  9. package/src/app/api/agents/route.ts +5 -1
  10. package/src/app/api/auth/route.ts +3 -1
  11. package/src/app/api/claude-skills/route.ts +3 -1
  12. package/src/app/api/connectors/[id]/route.ts +4 -0
  13. package/src/app/api/connectors/route.ts +6 -1
  14. package/src/app/api/credentials/route.ts +3 -1
  15. package/src/app/api/daemon/route.ts +6 -1
  16. package/src/app/api/ip/route.ts +3 -1
  17. package/src/app/api/mcp-servers/route.ts +3 -1
  18. package/src/app/api/orchestrator/graph/route.ts +25 -0
  19. package/src/app/api/plugins/marketplace/route.ts +3 -1
  20. package/src/app/api/plugins/route.ts +3 -1
  21. package/src/app/api/providers/[id]/route.ts +3 -0
  22. package/src/app/api/providers/configs/route.ts +3 -1
  23. package/src/app/api/providers/route.ts +5 -1
  24. package/src/app/api/schedules/[id]/route.ts +3 -0
  25. package/src/app/api/schedules/route.ts +6 -1
  26. package/src/app/api/secrets/route.ts +3 -1
  27. package/src/app/api/sessions/[id]/chat/route.ts +5 -2
  28. package/src/app/api/sessions/route.ts +9 -2
  29. package/src/app/api/settings/route.ts +3 -1
  30. package/src/app/api/setup/doctor/route.ts +1 -0
  31. package/src/app/api/setup/openclaw-device/route.ts +3 -1
  32. package/src/app/api/skills/route.ts +3 -1
  33. package/src/app/api/tasks/[id]/approve/route.ts +73 -0
  34. package/src/app/api/tasks/[id]/route.ts +3 -0
  35. package/src/app/api/tasks/route.ts +3 -0
  36. package/src/app/api/usage/route.ts +3 -1
  37. package/src/app/api/version/route.ts +3 -1
  38. package/src/app/api/webhooks/[id]/route.ts +2 -1
  39. package/src/app/api/webhooks/route.ts +3 -1
  40. package/src/app/icon.svg +58 -0
  41. package/src/app/page.tsx +8 -2
  42. package/src/cli/index.js +1 -9
  43. package/src/cli/index.ts +51 -1
  44. package/src/cli/spec.js +0 -8
  45. package/src/components/agents/agent-card.tsx +1 -1
  46. package/src/components/agents/agent-sheet.tsx +63 -80
  47. package/src/components/chat/chat-area.tsx +44 -30
  48. package/src/components/chat/chat-tool-toggles.tsx +12 -53
  49. package/src/components/chat/message-bubble.tsx +110 -42
  50. package/src/components/chat/tool-call-bubble.tsx +41 -3
  51. package/src/components/chat/tool-request-banner.tsx +1 -9
  52. package/src/components/connectors/connector-list.tsx +3 -8
  53. package/src/components/connectors/connector-sheet.tsx +24 -29
  54. package/src/components/input/chat-input.tsx +72 -56
  55. package/src/components/knowledge/knowledge-list.tsx +27 -31
  56. package/src/components/layout/app-layout.tsx +92 -71
  57. package/src/components/layout/daemon-indicator.tsx +3 -5
  58. package/src/components/logs/log-list.tsx +5 -9
  59. package/src/components/mcp-servers/mcp-server-list.tsx +24 -2
  60. package/src/components/memory/memory-detail.tsx +1 -1
  61. package/src/components/plugins/plugin-list.tsx +227 -27
  62. package/src/components/providers/provider-list.tsx +46 -13
  63. package/src/components/providers/provider-sheet.tsx +0 -45
  64. package/src/components/runs/run-list.tsx +6 -15
  65. package/src/components/schedules/schedule-card.tsx +54 -4
  66. package/src/components/schedules/schedule-list.tsx +6 -3
  67. package/src/components/schedules/schedule-sheet.tsx +0 -47
  68. package/src/components/secrets/secrets-list.tsx +20 -2
  69. package/src/components/sessions/new-session-sheet.tsx +8 -9
  70. package/src/components/shared/connector-platform-icon.tsx +22 -20
  71. package/src/components/shared/model-combobox.tsx +148 -0
  72. package/src/components/shared/settings/section-heartbeat.tsx +7 -39
  73. package/src/components/shared/settings/section-orchestrator.tsx +8 -9
  74. package/src/components/skills/skill-list.tsx +260 -34
  75. package/src/components/skills/skill-sheet.tsx +0 -45
  76. package/src/components/tasks/task-board.tsx +3 -6
  77. package/src/components/tasks/task-card.tsx +43 -1
  78. package/src/components/tasks/task-list.tsx +3 -5
  79. package/src/components/tasks/task-sheet.tsx +0 -44
  80. package/src/components/usage/usage-list.tsx +12 -4
  81. package/src/hooks/use-ws.ts +66 -0
  82. package/src/instrumentation.ts +2 -0
  83. package/src/lib/chat.ts +14 -2
  84. package/src/lib/providers/anthropic.ts +1 -1
  85. package/src/lib/providers/index.ts +2 -0
  86. package/src/lib/providers/ollama.ts +1 -1
  87. package/src/lib/providers/openai.ts +33 -12
  88. package/src/lib/server/chat-execution.ts +19 -4
  89. package/src/lib/server/connectors/manager.ts +9 -3
  90. package/src/lib/server/context-manager.ts +1 -1
  91. package/src/lib/server/daemon-state.ts +3 -0
  92. package/src/lib/server/data-dir.ts +1 -0
  93. package/src/lib/server/heartbeat-service.ts +67 -3
  94. package/src/lib/server/langgraph-checkpoint.ts +274 -0
  95. package/src/lib/server/main-agent-loop.ts +61 -2
  96. package/src/lib/server/orchestrator-lg.ts +394 -13
  97. package/src/lib/server/orchestrator.ts +25 -5
  98. package/src/lib/server/queue.ts +17 -3
  99. package/src/lib/server/session-run-manager.ts +6 -1
  100. package/src/lib/server/session-tools/delegate.ts +2 -2
  101. package/src/lib/server/session-tools/index.ts +2 -0
  102. package/src/lib/server/session-tools/sandbox.ts +164 -0
  103. package/src/lib/server/storage-mcp.test.ts +25 -2
  104. package/src/lib/server/storage.ts +24 -7
  105. package/src/lib/server/stream-agent-chat.ts +77 -22
  106. package/src/lib/server/task-validation.test.ts +23 -0
  107. package/src/lib/server/task-validation.ts +5 -3
  108. package/src/lib/server/ws-hub.ts +85 -0
  109. package/src/lib/tool-definitions.ts +42 -0
  110. package/src/lib/upload.ts +7 -1
  111. package/src/lib/ws-client.ts +124 -0
  112. package/src/stores/use-chat-store.ts +33 -13
  113. package/src/types/index.ts +8 -1
  114. package/src/app/api/agents/generate/route.ts +0 -42
  115. package/src/app/api/generate/info/route.ts +0 -12
  116. package/src/app/api/generate/route.ts +0 -106
  117. package/src/app/favicon.ico +0 -0
  118. package/src/components/shared/ai-gen-block.tsx +0 -77
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useState, useMemo } from 'react'
4
4
  import type { ToolEvent } from '@/stores/use-chat-store'
5
+ import { useAppStore } from '@/stores/use-app-store'
5
6
 
6
7
  const TOOL_COLORS: Record<string, string> = {
7
8
  execute_command: '#F59E0B',
@@ -15,6 +16,7 @@ const TOOL_COLORS: Record<string, string> = {
15
16
  send_file: '#10B981',
16
17
  web_search: '#3B82F6',
17
18
  web_fetch: '#3B82F6',
19
+ delegate_to_agent: '#6366F1',
18
20
  delegate_to_claude_code: '#6366F1',
19
21
  delegate_to_codex_cli: '#0EA5E9',
20
22
  delegate_to_opencode_cli: '#14B8A6',
@@ -63,6 +65,7 @@ export const TOOL_LABELS: Record<string, string> = {
63
65
  claude_code: 'Claude Code',
64
66
  codex_cli: 'Codex CLI',
65
67
  opencode_cli: 'OpenCode CLI',
68
+ delegate_to_agent: 'Agent Delegation',
66
69
  delegate_to_claude_code: 'Claude Code',
67
70
  delegate_to_codex_cli: 'Codex CLI',
68
71
  delegate_to_opencode_cli: 'OpenCode CLI',
@@ -96,6 +99,7 @@ export const TOOL_DESCRIPTIONS: Record<string, string> = {
96
99
  claude_code: 'Enable delegation to Claude Code CLI',
97
100
  codex_cli: 'Enable delegation to OpenAI Codex CLI',
98
101
  opencode_cli: 'Enable delegation to OpenCode CLI',
102
+ delegate_to_agent: 'Delegate a task to another agent',
99
103
  delegate_to_claude_code: 'Delegate complex coding tasks to Claude Code',
100
104
  delegate_to_codex_cli: 'Delegate complex coding tasks to Codex CLI',
101
105
  delegate_to_opencode_cli: 'Delegate complex coding tasks to OpenCode CLI',
@@ -181,6 +185,7 @@ function getInputPreview(name: string, input: string): string {
181
185
  return ''
182
186
  }
183
187
  if (name === 'send_file') return parsed.filePath || ''
188
+ if (name === 'delegate_to_agent') return `${parsed.agentName}: ${(parsed.task || '').slice(0, 80)}`
184
189
 
185
190
  if (parsed.command) return parsed.command
186
191
  if (parsed.filePath) return parsed.filePath
@@ -280,6 +285,24 @@ export function ToolCallBubble({ event }: { event: ToolEvent }) {
280
285
 
281
286
  const hasMedia = media.images.length > 0 || media.videos.length > 0 || media.pdfs.length > 0 || media.files.length > 0
282
287
 
288
+ // Parse delegation info for clickable agent link
289
+ const delegationInfo = useMemo(() => {
290
+ if (event.name !== 'delegate_to_agent') return null
291
+ try {
292
+ const parsed = JSON.parse(event.input)
293
+ return { agentName: parsed.agentName || '', agentId: parsed.agentId || '', task: parsed.task || '' }
294
+ } catch { return null }
295
+ }, [event.name, event.input])
296
+
297
+ const handleAgentClick = (e: React.MouseEvent) => {
298
+ e.stopPropagation()
299
+ if (delegationInfo?.agentId) {
300
+ const store = useAppStore.getState()
301
+ store.setActiveView('agents')
302
+ store.setCurrentAgent(delegationInfo.agentId)
303
+ }
304
+ }
305
+
283
306
  return (
284
307
  <div className="w-full text-left">
285
308
  <button
@@ -303,9 +326,24 @@ export function ToolCallBubble({ event }: { event: ToolEvent }) {
303
326
  <span className="text-[12px] font-700 uppercase tracking-wider shrink-0" style={{ color }}>
304
327
  {label}
305
328
  </span>
306
- <span className="text-[12px] text-text-2 font-mono truncate flex-1">
307
- {inputPreview}
308
- </span>
329
+ {delegationInfo ? (
330
+ <span className="text-[12px] text-text-2 font-mono truncate flex-1">
331
+ <span
332
+ role="link"
333
+ tabIndex={0}
334
+ onClick={handleAgentClick}
335
+ onKeyDown={(e) => e.key === 'Enter' && handleAgentClick(e as any)}
336
+ className="text-accent-bright hover:underline cursor-pointer font-600"
337
+ >
338
+ {delegationInfo.agentName}
339
+ </span>
340
+ {delegationInfo.task && <span className="text-text-3">: {delegationInfo.task.slice(0, 80)}</span>}
341
+ </span>
342
+ ) : (
343
+ <span className="text-[12px] text-text-2 font-mono truncate flex-1">
344
+ {inputPreview}
345
+ </span>
346
+ )}
309
347
  {hasMedia && !expanded && (
310
348
  <span className="text-[10px] text-text-3/50 font-500 shrink-0">
311
349
  {media.images.length > 0 && `${media.images.length} image${media.images.length > 1 ? 's' : ''}`}
@@ -3,15 +3,7 @@
3
3
  import { useState } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
5
  import { api } from '@/lib/api-client'
6
-
7
- const TOOL_LABELS: Record<string, string> = {
8
- shell: 'Shell', files: 'Files', edit_file: 'Edit File', process: 'Process',
9
- web_search: 'Web Search', web_fetch: 'Web Fetch', browser: 'Browser', memory: 'Memory',
10
- claude_code: 'Claude Code', codex_cli: 'Codex CLI', opencode_cli: 'OpenCode CLI',
11
- orchestrator: 'Orchestrator', manage_agents: 'Agents', manage_tasks: 'Tasks', manage_schedules: 'Schedules',
12
- manage_skills: 'Skills', manage_documents: 'Documents', manage_webhooks: 'Webhooks',
13
- manage_connectors: 'Connectors', manage_sessions: 'Sessions', manage_secrets: 'Secrets',
14
- }
6
+ import { TOOL_LABELS } from '@/lib/tool-definitions'
15
7
 
16
8
  interface Props {
17
9
  text: string
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useCallback, useEffect, useState } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
+ import { useWs } from '@/hooks/use-ws'
5
6
  import { api } from '@/lib/api-client'
6
7
  import type { Connector } from '@/types'
7
8
  import { ConnectorPlatformBadge, getConnectorPlatformLabel } from '@/components/shared/connector-platform-icon'
@@ -24,14 +25,8 @@ export function ConnectorList({ inSidebar: _inSidebar }: { inSidebar?: boolean }
24
25
  setLoaded(true)
25
26
  }, [loadConnectors, loadAgents])
26
27
 
27
- useEffect(() => {
28
- const bootstrap = setTimeout(() => { void refresh() }, 0)
29
- const poll = setInterval(() => { void loadConnectors() }, 15_000)
30
- return () => {
31
- clearTimeout(bootstrap)
32
- clearInterval(poll)
33
- }
34
- }, [refresh, loadConnectors])
28
+ useEffect(() => { void refresh() }, [refresh])
29
+ useWs('connectors', loadConnectors, 15_000)
35
30
 
36
31
  // Auto-clear error after 5s
37
32
  useEffect(() => {
@@ -1,9 +1,10 @@
1
1
  'use client'
2
2
 
3
- import { useState, useEffect } from 'react'
3
+ import { useState, useEffect, useCallback } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
5
  import { BottomSheet } from '@/components/shared/bottom-sheet'
6
6
  import { api } from '@/lib/api-client'
7
+ import { useWs } from '@/hooks/use-ws'
7
8
  import { toast } from 'sonner'
8
9
  import type { Connector, ConnectorPlatform } from '@/types'
9
10
  import { ConnectorPlatformBadge } from '@/components/shared/connector-platform-icon'
@@ -255,35 +256,29 @@ export function ConnectorSheet() {
255
256
 
256
257
  // Poll for QR code when WhatsApp connector is running or connecting
257
258
  const isWaRunning = editing?.platform === 'whatsapp' && (editing?.status === 'running' || waConnecting)
258
- useEffect(() => {
259
- if (!editing || !isWaRunning) {
260
- return
261
- }
262
- let cancelled = false
263
- const poll = async () => {
264
- try {
265
- const data = await api<any>('GET', `/connectors/${editing.id}`)
266
- if (!cancelled) {
267
- setQrDataUrl(data.qrDataUrl || null)
268
- setWaAuthenticated(data.authenticated ?? false)
269
- setWaHasCreds(data.hasCredentials ?? false)
270
- // Sync store with the individual endpoint's runtime status
271
- if (data.status === 'running' && editing.status !== 'running') {
272
- // Store is stale — update it directly
273
- const store = useAppStore.getState()
274
- const updated = { ...store.connectors }
275
- if (updated[editing.id]) {
276
- updated[editing.id] = { ...updated[editing.id], status: 'running' as const }
277
- useAppStore.setState({ connectors: updated })
278
- }
279
- }
259
+ const pollWaStatus = useCallback(async () => {
260
+ if (!editing) return
261
+ try {
262
+ const data = await api<any>('GET', `/connectors/${editing.id}`)
263
+ setQrDataUrl(data.qrDataUrl || null)
264
+ setWaAuthenticated(data.authenticated ?? false)
265
+ setWaHasCreds(data.hasCredentials ?? false)
266
+ if (data.status === 'running' && editing.status !== 'running') {
267
+ const store = useAppStore.getState()
268
+ const updated = { ...store.connectors }
269
+ if (updated[editing.id]) {
270
+ updated[editing.id] = { ...updated[editing.id], status: 'running' as const }
271
+ useAppStore.setState({ connectors: updated })
280
272
  }
281
- } catch { /* ignore */ }
282
- }
283
- poll()
284
- const interval = setInterval(poll, 2000)
285
- return () => { cancelled = true; clearInterval(interval) }
286
- }, [editing?.id, isWaRunning])
273
+ }
274
+ } catch { /* ignore */ }
275
+ }, [editing?.id, editing?.status])
276
+
277
+ useEffect(() => {
278
+ if (editing && isWaRunning) pollWaStatus()
279
+ }, [editing?.id, isWaRunning, pollWaStatus])
280
+
281
+ useWs('connectors', pollWaStatus, isWaRunning ? 2000 : undefined)
287
282
 
288
283
  const handleSave = async () => {
289
284
  if (!agentId) return
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { useCallback, useRef, useState } from 'react'
4
- import { useChatStore } from '@/stores/use-chat-store'
4
+ import { useChatStore, type PendingFile } from '@/stores/use-chat-store'
5
5
  import { useAppStore } from '@/stores/use-app-store'
6
6
  import { uploadImage } from '@/lib/upload'
7
7
  import { useAutoResize } from '@/hooks/use-auto-resize'
@@ -13,23 +13,56 @@ interface Props {
13
13
  onStop: () => void
14
14
  }
15
15
 
16
+ function FilePreview({ file, onRemove }: { file: PendingFile; onRemove: () => void }) {
17
+ const isImage = file.file.type.startsWith('image/')
18
+ return (
19
+ <div className="relative">
20
+ {isImage ? (
21
+ <img
22
+ src={URL.createObjectURL(file.file)}
23
+ alt="Preview"
24
+ className="h-16 rounded-[10px] object-cover border border-white/[0.06]"
25
+ />
26
+ ) : (
27
+ <div className="flex items-center gap-2.5 px-3 py-2.5 rounded-[10px] border border-white/[0.06] bg-white/[0.03]">
28
+ <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">
29
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
30
+ <polyline points="14 2 14 8 20 8" />
31
+ </svg>
32
+ <span className="text-[13px] text-text-2 font-500 truncate max-w-[180px]">{file.file.name}</span>
33
+ </div>
34
+ )}
35
+ <button
36
+ onClick={onRemove}
37
+ className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full border border-white/10 bg-raised
38
+ text-text-2 text-[10px] cursor-pointer flex items-center justify-center
39
+ hover:bg-danger-soft hover:text-danger hover:border-danger/20 transition-colors"
40
+ >
41
+ &times;
42
+ </button>
43
+ </div>
44
+ )
45
+ }
46
+
16
47
  export function ChatInput({ streaming, onSend, onStop }: Props) {
17
48
  const [value, setValue] = useState('')
18
49
  const { ref: textareaRef, resize } = useAutoResize()
19
50
  const fileInputRef = useRef<HTMLInputElement>(null)
20
- const pendingImage = useChatStore((s) => s.pendingImage)
21
- const setPendingImage = useChatStore((s) => s.setPendingImage)
51
+ const imageInputRef = useRef<HTMLInputElement>(null)
52
+ const pendingFiles = useChatStore((s) => s.pendingFiles)
53
+ const addPendingFile = useChatStore((s) => s.addPendingFile)
54
+ const removePendingFile = useChatStore((s) => s.removePendingFile)
22
55
  const speechRecognitionLang = useAppStore((s) => s.appSettings.speechRecognitionLang)
23
56
 
24
57
  const handleSend = useCallback(() => {
25
58
  const text = value.trim()
26
- if (!text || streaming) return
27
- onSend(text)
59
+ if ((!text && !pendingFiles.length) || streaming) return
60
+ onSend(text || 'See attached file(s).')
28
61
  setValue('')
29
62
  if (textareaRef.current) {
30
63
  textareaRef.current.style.height = 'auto'
31
64
  }
32
- }, [value, streaming, onSend])
65
+ }, [value, streaming, onSend, pendingFiles.length])
33
66
 
34
67
  const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
35
68
  if (e.key === 'Enter' && !e.shiftKey) {
@@ -47,6 +80,15 @@ export function ChatInput({ streaming, onSend, onStop }: Props) {
47
80
  { lang: speechRecognitionLang || undefined },
48
81
  )
49
82
 
83
+ const uploadAndAdd = useCallback(async (file: File) => {
84
+ try {
85
+ const result = await uploadImage(file)
86
+ addPendingFile({ file, path: result.path, url: result.url })
87
+ } catch {
88
+ // ignore upload errors
89
+ }
90
+ }, [addPendingFile])
91
+
50
92
  const handlePaste = useCallback(async (e: React.ClipboardEvent) => {
51
93
  const items = e.clipboardData?.items
52
94
  if (!items) return
@@ -54,35 +96,22 @@ export function ChatInput({ streaming, onSend, onStop }: Props) {
54
96
  if (item.type.startsWith('image/')) {
55
97
  e.preventDefault()
56
98
  const file = item.getAsFile()
57
- if (!file) return
58
- try {
59
- const result = await uploadImage(file)
60
- setPendingImage({ file, path: result.path, url: result.url })
61
- } catch {
62
- // ignore
63
- }
99
+ if (file) await uploadAndAdd(file)
64
100
  return
65
101
  }
66
102
  }
67
- }, [])
103
+ }, [uploadAndAdd])
68
104
 
69
105
  const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
70
- const file = e.target.files?.[0]
71
- if (!file) return
72
- try {
73
- const result = await uploadImage(file)
74
- setPendingImage({
75
- file,
76
- path: result.path,
77
- url: result.url,
78
- })
79
- } catch {
80
- // ignore
106
+ const files = e.target.files
107
+ if (!files?.length) return
108
+ for (const file of Array.from(files)) {
109
+ await uploadAndAdd(file)
81
110
  }
82
111
  e.target.value = ''
83
- }, [])
112
+ }, [uploadAndAdd])
84
113
 
85
- const hasContent = value.trim().length > 0 || !!pendingImage
114
+ const hasContent = value.trim().length > 0 || pendingFiles.length > 0
86
115
 
87
116
  return (
88
117
  <div className="shrink-0 px-6 md:px-12 lg:px-16 pb-4 pt-2"
@@ -105,33 +134,11 @@ export function ChatInput({ streaming, onSend, onStop }: Props) {
105
134
  <div className="glass rounded-[20px] overflow-hidden
106
135
  shadow-[0_4px_32px_rgba(0,0,0,0.3)] focus-within:border-border-focus focus-within:shadow-[0_4px_32px_rgba(99,102,241,0.08)] transition-all duration-300">
107
136
 
108
- {pendingImage && (
109
- <div className="flex items-center gap-2 px-5 pt-4">
110
- <div className="relative">
111
- {pendingImage.file.type.startsWith('image/') ? (
112
- <img
113
- src={URL.createObjectURL(pendingImage.file)}
114
- alt="Preview"
115
- className="h-16 rounded-[10px] object-cover border border-white/[0.06]"
116
- />
117
- ) : (
118
- <div className="flex items-center gap-2.5 px-3 py-2.5 rounded-[10px] border border-white/[0.06] bg-white/[0.03]">
119
- <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">
120
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
121
- <polyline points="14 2 14 8 20 8" />
122
- </svg>
123
- <span className="text-[13px] text-text-2 font-500 truncate max-w-[180px]">{pendingImage.file.name}</span>
124
- </div>
125
- )}
126
- <button
127
- onClick={() => setPendingImage(null)}
128
- className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full border border-white/10 bg-raised
129
- text-text-2 text-[10px] cursor-pointer flex items-center justify-center
130
- hover:bg-danger-soft hover:text-danger hover:border-danger/20 transition-colors"
131
- >
132
- &times;
133
- </button>
134
- </div>
137
+ {pendingFiles.length > 0 && (
138
+ <div className="flex items-center gap-2 px-5 pt-4 flex-wrap">
139
+ {pendingFiles.map((f, i) => (
140
+ <FilePreview key={`${f.path}-${i}`} file={f} onRemove={() => removePendingFile(i)} />
141
+ ))}
135
142
  </div>
136
143
  )}
137
144
 
@@ -162,7 +169,7 @@ export function ChatInput({ streaming, onSend, onStop }: Props) {
162
169
  </button>
163
170
 
164
171
  <button
165
- onClick={() => fileInputRef.current?.click()}
172
+ onClick={() => imageInputRef.current?.click()}
166
173
  className="flex items-center gap-1.5 px-3 py-2 rounded-[10px] border-none bg-transparent
167
174
  text-text-3 text-[13px] cursor-pointer hover:text-text-2 hover:bg-white/[0.05] transition-all duration-200"
168
175
  style={{ fontFamily: 'inherit' }}
@@ -217,7 +224,16 @@ export function ChatInput({ streaming, onSend, onStop }: Props) {
217
224
  <input
218
225
  ref={fileInputRef}
219
226
  type="file"
220
- accept="image/*,.pdf,.txt,.md,.csv,.json,.xml,.html,.js,.ts,.tsx,.jsx,.py,.go,.rs,.java,.c,.cpp,.h,.yml,.yaml,.toml,.env,.log,.sh,.sql"
227
+ multiple
228
+ accept="image/*,.pdf,.txt,.md,.csv,.json,.xml,.html,.js,.ts,.tsx,.jsx,.py,.go,.rs,.java,.c,.cpp,.h,.yml,.yaml,.toml,.env,.log,.sh,.sql,.css,.scss"
229
+ onChange={handleFileChange}
230
+ className="hidden"
231
+ />
232
+ <input
233
+ ref={imageInputRef}
234
+ type="file"
235
+ multiple
236
+ accept="image/*"
221
237
  onChange={handleFileChange}
222
238
  className="hidden"
223
239
  />
@@ -12,7 +12,6 @@ export function KnowledgeList() {
12
12
  const [loaded, setLoaded] = useState(false)
13
13
  const [error, setError] = useState<string | null>(null)
14
14
  const [activeTag, setActiveTag] = useState<string | null>(null)
15
- const [selectedId, setSelectedId] = useState<string | null>(null)
16
15
  const searchRef = useRef(search)
17
16
  const setKnowledgeSheetOpen = useAppStore((s) => s.setKnowledgeSheetOpen)
18
17
  const setEditingKnowledgeId = useAppStore((s) => s.setEditingKnowledgeId)
@@ -64,7 +63,6 @@ export function KnowledgeList() {
64
63
  try {
65
64
  await api('DELETE', `/knowledge/${id}`)
66
65
  setEntries((prev) => prev.filter((e) => e.id !== id))
67
- if (selectedId === id) setSelectedId(null)
68
66
  } catch {
69
67
  // silent
70
68
  }
@@ -78,7 +76,7 @@ export function KnowledgeList() {
78
76
  <div className="flex-1 flex flex-col overflow-y-auto">
79
77
  {/* Search — only show when there are entries */}
80
78
  {entries.length > 0 && (
81
- <div className="px-3 py-2 shrink-0">
79
+ <div className="px-5 py-2 shrink-0">
82
80
  <input
83
81
  type="text"
84
82
  value={search}
@@ -93,7 +91,7 @@ export function KnowledgeList() {
93
91
 
94
92
  {/* Tag filters */}
95
93
  {uniqueTags.length > 0 && (
96
- <div className="px-3 pb-1.5 shrink-0">
94
+ <div className="px-5 pb-1.5 shrink-0">
97
95
  <div className="flex gap-1 flex-wrap">
98
96
  <button
99
97
  onClick={() => setActiveTag(null)}
@@ -120,52 +118,50 @@ export function KnowledgeList() {
120
118
 
121
119
  {/* Entries */}
122
120
  {entries.length > 0 ? (
123
- <div className="flex flex-col gap-0.5 px-2 pb-4">
121
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3 px-5 pb-6">
124
122
  {entries.map((entry) => {
125
123
  const meta = entry.metadata as { tags?: string[] } | undefined
126
124
  const tags = meta?.tags || []
127
125
  return (
128
126
  <div
129
127
  key={entry.id}
130
- onClick={() => setSelectedId(selectedId === entry.id ? null : entry.id)}
131
- className={`p-3 rounded-[12px] border cursor-pointer transition-all
132
- ${selectedId === entry.id
133
- ? 'border-accent-bright/30 bg-accent-soft/30'
134
- : 'border-white/[0.04] bg-transparent hover:bg-surface-2'
135
- }`}
128
+ className="p-3 rounded-[12px] border border-white/[0.04] bg-transparent hover:bg-surface-2 transition-all relative group"
136
129
  >
137
130
  <div className="flex items-start justify-between gap-2 mb-1">
138
131
  <span className="font-display text-[13px] font-600 text-text truncate">{entry.title}</span>
139
- <span className="text-[10px] text-text-3/50 shrink-0">{formatDate(entry.createdAt)}</span>
132
+ <div className="flex items-center gap-1.5 shrink-0">
133
+ <button
134
+ onClick={() => openSheet(entry.id)}
135
+ className="text-text-3/40 hover:text-accent-bright transition-colors p-0.5 cursor-pointer"
136
+ title="Edit"
137
+ >
138
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
139
+ <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
140
+ <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
141
+ </svg>
142
+ </button>
143
+ <button
144
+ onClick={() => void handleDelete(entry.id)}
145
+ className="text-text-3/40 hover:text-red-400 transition-colors p-0.5 cursor-pointer"
146
+ title="Delete"
147
+ >
148
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
149
+ <path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
150
+ </svg>
151
+ </button>
152
+ <span className="text-[10px] text-text-3/50">{formatDate(entry.createdAt)}</span>
153
+ </div>
140
154
  </div>
141
155
  <p className="text-[11px] text-text-3/60 line-clamp-2 mb-2">
142
156
  {entry.content.slice(0, 200)}
143
157
  </p>
144
158
  {tags.length > 0 && (
145
- <div className="flex gap-1 flex-wrap mb-2">
159
+ <div className="flex gap-1 flex-wrap">
146
160
  {tags.map((t) => (
147
161
  <Badge key={t} variant="secondary" className="text-[9px] px-1.5 py-0">{t}</Badge>
148
162
  ))}
149
163
  </div>
150
164
  )}
151
- {selectedId === entry.id && (
152
- <div className="flex gap-2 pt-2 border-t border-white/[0.04]">
153
- <button
154
- onClick={(e) => { e.stopPropagation(); openSheet(entry.id) }}
155
- className="px-2.5 py-1 rounded-[7px] text-[10px] font-600 text-accent-bright bg-accent-soft cursor-pointer border-none hover:brightness-110 transition-all"
156
- style={{ fontFamily: 'inherit' }}
157
- >
158
- Edit
159
- </button>
160
- <button
161
- onClick={(e) => { e.stopPropagation(); void handleDelete(entry.id) }}
162
- className="px-2.5 py-1 rounded-[7px] text-[10px] font-600 text-red-400 bg-red-400/10 cursor-pointer border-none hover:bg-red-400/20 transition-all"
163
- style={{ fontFamily: 'inherit' }}
164
- >
165
- Delete
166
- </button>
167
- </div>
168
- )}
169
165
  </div>
170
166
  )
171
167
  })}