@swarmclawai/swarmclaw 0.6.2 → 0.6.4

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 (42) hide show
  1. package/README.md +45 -44
  2. package/package.json +1 -1
  3. package/src/app/api/tts/route.ts +16 -36
  4. package/src/app/api/tts/stream/route.ts +14 -43
  5. package/src/app/page.tsx +7 -3
  6. package/src/components/auth/access-key-gate.tsx +22 -11
  7. package/src/components/chat/chat-area.tsx +30 -2
  8. package/src/components/chat/chat-header.tsx +70 -3
  9. package/src/components/chat/message-bubble.tsx +11 -1
  10. package/src/components/chat/message-list.tsx +3 -71
  11. package/src/components/chat/tool-call-bubble.test.ts +28 -0
  12. package/src/components/chat/tool-call-bubble.tsx +13 -1
  13. package/src/components/chatrooms/chatroom-input.tsx +6 -5
  14. package/src/components/connectors/connector-sheet.tsx +16 -1
  15. package/src/components/input/chat-input.tsx +5 -4
  16. package/src/components/layout/app-layout.tsx +5 -6
  17. package/src/components/logs/log-list.tsx +7 -7
  18. package/src/components/sessions/new-session-sheet.tsx +4 -3
  19. package/src/hooks/use-media-query.ts +30 -4
  20. package/src/lib/api-client.ts +6 -18
  21. package/src/lib/fetch-timeout.ts +17 -0
  22. package/src/lib/notification-sounds.ts +4 -4
  23. package/src/lib/safe-storage.ts +42 -0
  24. package/src/lib/server/chat-execution.ts +74 -3
  25. package/src/lib/server/connectors/connector-routing.test.ts +118 -1
  26. package/src/lib/server/connectors/discord.ts +31 -8
  27. package/src/lib/server/connectors/manager.ts +398 -31
  28. package/src/lib/server/connectors/media.ts +5 -0
  29. package/src/lib/server/connectors/telegram.ts +12 -2
  30. package/src/lib/server/connectors/types.ts +2 -0
  31. package/src/lib/server/connectors/whatsapp.ts +28 -2
  32. package/src/lib/server/elevenlabs.test.ts +60 -0
  33. package/src/lib/server/elevenlabs.ts +103 -0
  34. package/src/lib/server/queue.ts +130 -1
  35. package/src/lib/server/session-tools/connector.ts +540 -94
  36. package/src/lib/server/session-tools/file.ts +26 -7
  37. package/src/lib/server/session-tools/web-output.test.ts +29 -0
  38. package/src/lib/server/session-tools/web-output.ts +16 -0
  39. package/src/lib/server/session-tools/web.ts +8 -5
  40. package/src/lib/server/stream-agent-chat.ts +7 -0
  41. package/src/lib/view-routes.ts +5 -1
  42. package/src/stores/use-app-store.ts +9 -11
@@ -6,7 +6,6 @@ import { useChatStore } from '@/stores/use-chat-store'
6
6
  import { useAppStore } from '@/stores/use-app-store'
7
7
  import { api } from '@/lib/api-client'
8
8
  import { AgentAvatar } from '@/components/agents/agent-avatar'
9
- import { ConnectorPlatformIcon, CONNECTOR_PLATFORM_META } from '@/components/shared/connector-platform-icon'
10
9
  import { MessageBubble } from './message-bubble'
11
10
  import { StreamingBubble } from './streaming-bubble'
12
11
  import { ThinkingIndicator } from './thinking-indicator'
@@ -47,9 +46,10 @@ function dateSeparator(ts: number): string {
47
46
  interface Props {
48
47
  messages: Message[]
49
48
  streaming: boolean
49
+ connectorFilter?: string | null
50
50
  }
51
51
 
52
- export function MessageList({ messages, streaming }: Props) {
52
+ export function MessageList({ messages, streaming, connectorFilter = null }: Props) {
53
53
  const scrollRef = useRef<HTMLDivElement>(null)
54
54
  const [showScrollToBottom, setShowScrollToBottom] = useState(false)
55
55
  const snapUntilRef = useRef(0)
@@ -119,9 +119,7 @@ export function MessageList({ messages, streaming }: Props) {
119
119
  // Bookmark filter
120
120
  const [bookmarkFilter, setBookmarkFilter] = useState(false)
121
121
 
122
- // Connector source filter
123
- const [connectorFilter, setConnectorFilter] = useState<string | null>(null)
124
- const [connectorFilterCollapsed, setConnectorFilterCollapsed] = useState(false)
122
+ // Connector filtering is handled via connectorFilter prop from chat-area
125
123
 
126
124
  const toggleBookmark = useCallback(async (index: number) => {
127
125
  if (!sessionId) return
@@ -181,17 +179,6 @@ export function MessageList({ messages, streaming }: Props) {
181
179
  }
182
180
  }
183
181
 
184
- // Collect unique connector sources for filter UI
185
- const connectorSources = new Map<string, { platform: string; connectorName: string }>()
186
- for (const msg of displayedMessages) {
187
- if (msg.source?.connectorId && !connectorSources.has(msg.source.connectorId)) {
188
- connectorSources.set(msg.source.connectorId, {
189
- platform: msg.source.platform,
190
- connectorName: msg.source.connectorName,
191
- })
192
- }
193
- }
194
-
195
182
  // Apply bookmark + connector filter
196
183
  let filteredMessages = bookmarkFilter
197
184
  ? displayedMessages.filter((msg) => msg.bookmarked)
@@ -407,61 +394,6 @@ export function MessageList({ messages, streaming }: Props) {
407
394
  </div>
408
395
  )}
409
396
 
410
- {/* Connector source filter — shown when connector messages exist */}
411
- {connectorSources.size > 0 && (
412
- <div className="flex items-center gap-1.5 px-6 md:px-12 lg:px-16 py-1.5 border-b border-white/[0.04]">
413
- <button
414
- onClick={() => setConnectorFilterCollapsed((c) => !c)}
415
- className="flex items-center gap-1 text-[10px] text-text-3/50 uppercase tracking-wider font-600 mr-1 bg-transparent border-none cursor-pointer hover:text-text-3/70 transition-colors p-0"
416
- title={connectorFilterCollapsed ? 'Expand source filter' : 'Collapse source filter'}
417
- >
418
- <svg
419
- width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"
420
- className="transition-transform duration-200"
421
- style={{ transform: connectorFilterCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)' }}
422
- >
423
- <polyline points="6 9 12 15 18 9" />
424
- </svg>
425
- Source
426
- {connectorFilterCollapsed && connectorFilter && (
427
- <span className="text-accent-bright/70 normal-case tracking-normal">
428
- ({connectorSources.get(connectorFilter)?.connectorName || connectorFilter})
429
- </span>
430
- )}
431
- </button>
432
- {!connectorFilterCollapsed && (
433
- <>
434
- <button
435
- onClick={() => setConnectorFilter(null)}
436
- className={`px-2 py-1 rounded-[6px] text-[11px] font-600 cursor-pointer border-none transition-all ${
437
- !connectorFilter ? 'bg-accent-soft text-accent-bright' : 'bg-transparent text-text-3 hover:text-text-2 hover:bg-white/[0.04]'
438
- }`}
439
- style={{ fontFamily: 'inherit' }}
440
- >
441
- All
442
- </button>
443
- {Array.from(connectorSources.entries()).map(([cid, info]) => {
444
- const active = connectorFilter === cid
445
- const meta = CONNECTOR_PLATFORM_META[info.platform as keyof typeof CONNECTOR_PLATFORM_META]
446
- return (
447
- <button
448
- key={cid}
449
- onClick={() => setConnectorFilter(active ? null : cid)}
450
- className={`flex items-center gap-1.5 px-2 py-1 rounded-[6px] text-[11px] font-600 cursor-pointer border-none transition-all ${
451
- active ? 'bg-accent-soft text-accent-bright' : 'bg-transparent text-text-3 hover:text-text-2 hover:bg-white/[0.04]'
452
- }`}
453
- style={{ fontFamily: 'inherit' }}
454
- >
455
- <ConnectorPlatformIcon platform={info.platform as keyof typeof CONNECTOR_PLATFORM_META} size={12} />
456
- {info.connectorName || meta?.label || info.platform}
457
- </button>
458
- )
459
- })}
460
- </>
461
- )}
462
- </div>
463
- )}
464
-
465
397
  <div
466
398
  ref={scrollRef}
467
399
  onScroll={updateScrollState}
@@ -0,0 +1,28 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { extractMedia } from './tool-call-bubble'
4
+
5
+ describe('extractMedia', () => {
6
+ it('dedupes browser-* screenshot variants when screenshot-* exists', () => {
7
+ const output = [
8
+ '![Screenshot](/api/uploads/browser-1772498741525.png)',
9
+ '![Screenshot](/api/uploads/screenshot-1772498741526.png)',
10
+ 'Saved to: example_screenshot.png',
11
+ ].join('\n')
12
+
13
+ const media = extractMedia(output)
14
+ assert.deepEqual(media.images, ['/api/uploads/screenshot-1772498741526.png'])
15
+ assert.equal(media.cleanText, 'Saved to: example_screenshot.png')
16
+ })
17
+
18
+ it('keeps browser-* screenshot when it is the only image artifact', () => {
19
+ const output = [
20
+ '![Screenshot](/api/uploads/browser-1772498741525.png)',
21
+ 'Saved to: example_screenshot.png',
22
+ ].join('\n')
23
+
24
+ const media = extractMedia(output)
25
+ assert.deepEqual(media.images, ['/api/uploads/browser-1772498741525.png'])
26
+ assert.equal(media.cleanText, 'Saved to: example_screenshot.png')
27
+ })
28
+ })
@@ -265,6 +265,7 @@ export function extractMedia(output: string): { images: string[]; videos: string
265
265
  const videos: string[] = []
266
266
  const pdfs: { name: string; url: string }[] = []
267
267
  const files: { name: string; url: string }[] = []
268
+ const imageEntries: Array<{ filename: string; url: string }> = []
268
269
 
269
270
  // Extract ![alt](/api/uploads/filename) — detect videos vs images by extension
270
271
  let cleanText = output.replace(/!\[([^\]]*)\]\(\/api\/uploads\/([^)]+)\)/g, (_match, _alt, filename) => {
@@ -272,7 +273,7 @@ export function extractMedia(output: string): { images: string[]; videos: string
272
273
  if (/\.(mp4|webm|mov|avi)$/i.test(filename)) {
273
274
  videos.push(url)
274
275
  } else {
275
- images.push(url)
276
+ imageEntries.push({ filename, url })
276
277
  }
277
278
  return ''
278
279
  })
@@ -291,6 +292,17 @@ export function extractMedia(output: string): { images: string[]; videos: string
291
292
  // Clean up leftover whitespace
292
293
  cleanText = cleanText.replace(/\n{3,}/g, '\n\n').trim()
293
294
 
295
+ // Playwright screenshot calls can surface both browser-*.png and screenshot-*.png
296
+ // for the same capture; prefer screenshot-* to avoid duplicate UI images.
297
+ const hasScreenshotVariant = imageEntries.some((entry) => /^screenshot-\d+\./i.test(entry.filename))
298
+ const seenImages = new Set<string>()
299
+ for (const entry of imageEntries) {
300
+ if (hasScreenshotVariant && /^browser-\d+\./i.test(entry.filename)) continue
301
+ if (seenImages.has(entry.url)) continue
302
+ seenImages.add(entry.url)
303
+ images.push(entry.url)
304
+ }
305
+
294
306
  return { images, videos, pdfs, files, cleanText }
295
307
  }
296
308
 
@@ -5,6 +5,7 @@ import { AgentAvatar } from '@/components/agents/agent-avatar'
5
5
  import { FilePreview } from '@/components/shared/file-preview'
6
6
  import { useChatroomStore } from '@/stores/use-chatroom-store'
7
7
  import { uploadImage } from '@/lib/upload'
8
+ import { safeStorageGet, safeStorageRemove, safeStorageSet } from '@/lib/safe-storage'
8
9
  import type { Agent } from '@/types'
9
10
 
10
11
  interface Props {
@@ -33,7 +34,7 @@ export function ChatroomInput({ agents, onSend, disabled }: Props) {
33
34
  const draftTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
34
35
  useEffect(() => {
35
36
  if (!chatroomId) return
36
- const draft = localStorage.getItem(`sc_draft_cr_${chatroomId}`)
37
+ const draft = safeStorageGet(`sc_draft_cr_${chatroomId}`)
37
38
  setText(draft || '')
38
39
  }, [chatroomId])
39
40
 
@@ -42,8 +43,8 @@ export function ChatroomInput({ agents, onSend, disabled }: Props) {
42
43
  if (!chatroomId) return
43
44
  if (draftTimerRef.current) clearTimeout(draftTimerRef.current)
44
45
  draftTimerRef.current = setTimeout(() => {
45
- if (text) localStorage.setItem(`sc_draft_cr_${chatroomId}`, text)
46
- else localStorage.removeItem(`sc_draft_cr_${chatroomId}`)
46
+ if (text) safeStorageSet(`sc_draft_cr_${chatroomId}`, text)
47
+ else safeStorageRemove(`sc_draft_cr_${chatroomId}`)
47
48
  }, 300)
48
49
  return () => { if (draftTimerRef.current) clearTimeout(draftTimerRef.current) }
49
50
  }, [text, chatroomId])
@@ -167,7 +168,7 @@ export function ChatroomInput({ agents, onSend, disabled }: Props) {
167
168
  if ((text.trim() || pendingFiles.length) && !disabled) {
168
169
  onSend(text)
169
170
  setText('')
170
- if (chatroomId) localStorage.removeItem(`sc_draft_cr_${chatroomId}`)
171
+ if (chatroomId) safeStorageRemove(`sc_draft_cr_${chatroomId}`)
171
172
  setShowMentions(false)
172
173
  }
173
174
  }
@@ -294,7 +295,7 @@ export function ChatroomInput({ agents, onSend, disabled }: Props) {
294
295
  if ((text.trim() || pendingFiles.length) && !disabled) {
295
296
  onSend(text)
296
297
  setText('')
297
- if (chatroomId) localStorage.removeItem(`sc_draft_cr_${chatroomId}`)
298
+ if (chatroomId) safeStorageRemove(`sc_draft_cr_${chatroomId}`)
298
299
  setShowMentions(false)
299
300
  }
300
301
  }}
@@ -222,6 +222,21 @@ const PLATFORMS: {
222
222
  },
223
223
  ]
224
224
 
225
+ const COMMON_CONFIG_FIELDS: { key: string; label: string; placeholder: string; help?: string }[] = [
226
+ {
227
+ key: 'taskFollowups',
228
+ label: 'Task Follow-ups',
229
+ placeholder: 'true | false',
230
+ help: 'Enable automatic connector follow-up messages when this agent completes or fails a task.',
231
+ },
232
+ {
233
+ key: 'taskFollowupTemplate',
234
+ label: 'Task Follow-up Template',
235
+ placeholder: 'Task {status}: {title}\\n\\n{summary}',
236
+ help: 'Optional placeholders: {status}, {title}, {summary}, {taskId}.',
237
+ },
238
+ ]
239
+
225
240
  export function ConnectorSheet() {
226
241
  const open = useAppStore((s) => s.connectorSheetOpen)
227
242
  const setOpen = useAppStore((s) => s.setConnectorSheetOpen)
@@ -625,7 +640,7 @@ export function ConnectorSheet() {
625
640
  )}
626
641
 
627
642
  {/* Platform-specific config */}
628
- {platformConfig.configFields.map((field) => {
643
+ {[...platformConfig.configFields, ...COMMON_CONFIG_FIELDS].map((field) => {
629
644
  const isTagField = field.key === 'allowedJids' || field.key === 'channelIds' || field.key === 'chatIds' || field.key === 'allowFrom'
630
645
  if (isTagField) {
631
646
  const tags = (config[field.key] || '').split(',').map((s) => s.trim()).filter(Boolean)
@@ -8,6 +8,7 @@ import { useAutoResize } from '@/hooks/use-auto-resize'
8
8
  import { useSpeechRecognition } from '@/hooks/use-speech-recognition'
9
9
  import { FilePreview } from '@/components/shared/file-preview'
10
10
  import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
11
+ import { safeStorageGet, safeStorageRemove, safeStorageSet } from '@/lib/safe-storage'
11
12
 
12
13
  interface Props {
13
14
  streaming: boolean
@@ -36,7 +37,7 @@ export function ChatInput({ streaming, onSend, onStop }: Props) {
36
37
  const draftTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
37
38
  useEffect(() => {
38
39
  if (!sessionId) return
39
- const draft = localStorage.getItem(`sc_draft_${sessionId}`)
40
+ const draft = safeStorageGet(`sc_draft_${sessionId}`)
40
41
  setValue(draft || '')
41
42
  }, [sessionId])
42
43
 
@@ -45,8 +46,8 @@ export function ChatInput({ streaming, onSend, onStop }: Props) {
45
46
  if (!sessionId) return
46
47
  if (draftTimerRef.current) clearTimeout(draftTimerRef.current)
47
48
  draftTimerRef.current = setTimeout(() => {
48
- if (value) localStorage.setItem(`sc_draft_${sessionId}`, value)
49
- else localStorage.removeItem(`sc_draft_${sessionId}`)
49
+ if (value) safeStorageSet(`sc_draft_${sessionId}`, value)
50
+ else safeStorageRemove(`sc_draft_${sessionId}`)
50
51
  }, 300)
51
52
  return () => { if (draftTimerRef.current) clearTimeout(draftTimerRef.current) }
52
53
  }, [value, sessionId])
@@ -65,7 +66,7 @@ export function ChatInput({ streaming, onSend, onStop }: Props) {
65
66
  }
66
67
  onSend(text || 'See attached file(s).')
67
68
  setValue('')
68
- if (sessionId) localStorage.removeItem(`sc_draft_${sessionId}`)
69
+ if (sessionId) safeStorageRemove(`sc_draft_${sessionId}`)
69
70
  if (textareaRef.current) {
70
71
  textareaRef.current.style.height = 'auto'
71
72
  }
@@ -58,6 +58,7 @@ import { ChatArea } from '@/components/chat/chat-area'
58
58
  import { CanvasPanel } from '@/components/canvas/canvas-panel'
59
59
  import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
60
60
  import { api } from '@/lib/api-client'
61
+ import { safeStorageGet, safeStorageSet } from '@/lib/safe-storage'
61
62
  import type { AppView } from '@/types'
62
63
 
63
64
  const RAIL_EXPANDED_KEY = 'sc_rail_expanded'
@@ -118,9 +119,8 @@ export function AppLayout() {
118
119
  }, [handleShortcutKey])
119
120
 
120
121
  useEffect(() => {
121
- if (typeof window === 'undefined') return
122
- if (localStorage.getItem(STAR_NOTIFICATION_KEY)) return
123
- localStorage.setItem(STAR_NOTIFICATION_KEY, '1')
122
+ if (safeStorageGet(STAR_NOTIFICATION_KEY)) return
123
+ safeStorageSet(STAR_NOTIFICATION_KEY, '1')
124
124
  void api('POST', '/notifications', {
125
125
  type: 'info',
126
126
  title: 'Enjoying SwarmClaw?',
@@ -143,15 +143,14 @@ export function AppLayout() {
143
143
  }, [appSettings.themeHue])
144
144
 
145
145
  const [railExpanded, setRailExpanded] = useState(() => {
146
- if (typeof window === 'undefined') return true
147
- const stored = localStorage.getItem(RAIL_EXPANDED_KEY)
146
+ const stored = safeStorageGet(RAIL_EXPANDED_KEY)
148
147
  return stored === null ? true : stored === 'true'
149
148
  })
150
149
 
151
150
  const toggleRail = () => {
152
151
  const next = !railExpanded
153
152
  setRailExpanded(next)
154
- localStorage.setItem(RAIL_EXPANDED_KEY, String(next))
153
+ safeStorageSet(RAIL_EXPANDED_KEY, String(next))
155
154
  }
156
155
 
157
156
  const handleSwitchUser = () => {
@@ -5,6 +5,7 @@ import { api } from '@/lib/api-client'
5
5
  import { useWs } from '@/hooks/use-ws'
6
6
  import { useAppStore } from '@/stores/use-app-store'
7
7
  import { BottomSheet } from '@/components/shared/bottom-sheet'
8
+ import { safeStorageGetJson, safeStorageSet } from '@/lib/safe-storage'
8
9
 
9
10
  interface LogEntry {
10
11
  time: string
@@ -38,10 +39,9 @@ export function LogList() {
38
39
  const [selected, setSelected] = useState<LogEntry | null>(null)
39
40
  const [creatingTask, setCreatingTask] = useState(false)
40
41
  const [taskAgentId, setTaskAgentId] = useState('')
41
- const [savedFilters, setSavedFilters] = useState<Array<{ name: string; levels: string[]; search: string }>>(() => {
42
- if (typeof window === 'undefined') return []
43
- try { return JSON.parse(localStorage.getItem('sc_log_filters') || '[]') } catch { return [] }
44
- })
42
+ const [savedFilters, setSavedFilters] = useState<Array<{ name: string; levels: string[]; search: string }>>(
43
+ () => safeStorageGetJson<Array<{ name: string; levels: string[]; search: string }>>('sc_log_filters', []),
44
+ )
45
45
  const scrollRef = useRef<HTMLDivElement>(null)
46
46
 
47
47
  const agents = useAppStore((s) => s.agents)
@@ -169,7 +169,7 @@ export function LogList() {
169
169
  onClick={(e) => {
170
170
  e.stopPropagation()
171
171
  const next = savedFilters.filter((_, j) => j !== i)
172
- localStorage.setItem('sc_log_filters', JSON.stringify(next))
172
+ safeStorageSet('sc_log_filters', JSON.stringify(next))
173
173
  setSavedFilters(next)
174
174
  }}
175
175
  className="text-accent-bright/50 hover:text-red-400 ml-0.5"
@@ -232,9 +232,9 @@ export function LogList() {
232
232
  const name = prompt('Filter name:')
233
233
  if (!name?.trim()) return
234
234
  const filter = { name: name.trim(), levels: levelFilter, search }
235
- const existing = JSON.parse(localStorage.getItem('sc_log_filters') || '[]')
235
+ const existing = safeStorageGetJson<Array<{ name: string; levels: string[]; search: string }>>('sc_log_filters', [])
236
236
  existing.push(filter)
237
- localStorage.setItem('sc_log_filters', JSON.stringify(existing))
237
+ safeStorageSet('sc_log_filters', JSON.stringify(existing))
238
238
  setSavedFilters(existing)
239
239
  }}
240
240
  className="px-2 py-1 rounded-[6px] text-[10px] font-600 cursor-pointer transition-all border-none bg-white/[0.04] text-text-3 hover:text-accent-bright hover:bg-accent-soft"
@@ -13,6 +13,7 @@ import { SheetFooter } from '@/components/shared/sheet-footer'
13
13
  import { inputClass } from '@/components/shared/form-styles'
14
14
  import type { ProviderType, SessionTool } from '@/types'
15
15
  import { SectionLabel } from '@/components/shared/section-label'
16
+ import { safeStorageGet, safeStorageRemove, safeStorageSet } from '@/lib/safe-storage'
16
17
 
17
18
  export function NewSessionSheet() {
18
19
  const open = useAppStore((s) => s.newSessionOpen)
@@ -64,7 +65,7 @@ export function NewSessionSheet() {
64
65
  setOllamaMode('local')
65
66
  // Auto-select last used agent, or default agent if no history
66
67
  const agentsList = Object.values(agents)
67
- const lastAgentId = typeof window !== 'undefined' ? localStorage.getItem('swarmclaw-last-agent') : null
68
+ const lastAgentId = safeStorageGet('swarmclaw-last-agent')
68
69
  const lastAgent = lastAgentId ? agentsList.find((a) => a.id === lastAgentId) : null
69
70
  const defaultAgent = lastAgent || agentsList.find((a) => a.id === 'default') || agentsList[0]
70
71
  if (defaultAgent) {
@@ -153,9 +154,9 @@ export function NewSessionSheet() {
153
154
  )
154
155
  // Remember agent selection for next time
155
156
  if (selectedAgentId) {
156
- localStorage.setItem('swarmclaw-last-agent', selectedAgentId)
157
+ safeStorageSet('swarmclaw-last-agent', selectedAgentId)
157
158
  } else {
158
- localStorage.removeItem('swarmclaw-last-agent')
159
+ safeStorageRemove('swarmclaw-last-agent')
159
160
  }
160
161
  updateSessionInStore(s)
161
162
  setCurrentSession(s.id)
@@ -2,17 +2,43 @@
2
2
 
3
3
  import { useCallback, useSyncExternalStore } from 'react'
4
4
 
5
+ function supportsMatchMedia(): boolean {
6
+ return typeof window !== 'undefined' && typeof window.matchMedia === 'function'
7
+ }
8
+
9
+ function getMatch(query: string): boolean {
10
+ if (!supportsMatchMedia()) return false
11
+ try {
12
+ return window.matchMedia(query).matches
13
+ } catch {
14
+ return false
15
+ }
16
+ }
17
+
5
18
  export function useMediaQuery(query: string): boolean {
6
19
  const subscribe = useCallback(
7
20
  (callback: () => void) => {
8
- const mql = window.matchMedia(query)
9
- mql.addEventListener('change', callback)
10
- return () => mql.removeEventListener('change', callback)
21
+ if (!supportsMatchMedia()) return () => {}
22
+
23
+ let mql: MediaQueryList
24
+ try {
25
+ mql = window.matchMedia(query)
26
+ } catch {
27
+ return () => {}
28
+ }
29
+
30
+ if (typeof mql.addEventListener === 'function') {
31
+ mql.addEventListener('change', callback)
32
+ return () => mql.removeEventListener('change', callback)
33
+ }
34
+
35
+ mql.addListener(callback)
36
+ return () => mql.removeListener(callback)
11
37
  },
12
38
  [query],
13
39
  )
14
40
 
15
- const getSnapshot = () => window.matchMedia(query).matches
41
+ const getSnapshot = () => getMatch(query)
16
42
 
17
43
  // Return false during SSR — matches initial client render before hydration
18
44
  const getServerSnapshot = () => false
@@ -1,39 +1,27 @@
1
+ import { fetchWithTimeout } from '@/lib/fetch-timeout'
2
+ import { safeStorageGet, safeStorageSet, safeStorageRemove } from '@/lib/safe-storage'
3
+
1
4
  const ACCESS_KEY_STORAGE = 'sc_access_key'
2
5
  const DEFAULT_API_TIMEOUT_MS = 12_000
3
6
  const DEFAULT_GET_RETRIES = 2
4
7
  const RETRY_DELAY_BASE_MS = 300
5
8
 
6
9
  export function getStoredAccessKey(): string {
7
- if (typeof window === 'undefined') return ''
8
- return localStorage.getItem(ACCESS_KEY_STORAGE) || ''
10
+ return safeStorageGet(ACCESS_KEY_STORAGE) || ''
9
11
  }
10
12
 
11
13
  export function setStoredAccessKey(key: string) {
12
- localStorage.setItem(ACCESS_KEY_STORAGE, key)
14
+ safeStorageSet(ACCESS_KEY_STORAGE, key)
13
15
  }
14
16
 
15
17
  export function clearStoredAccessKey() {
16
- localStorage.removeItem(ACCESS_KEY_STORAGE)
18
+ safeStorageRemove(ACCESS_KEY_STORAGE)
17
19
  }
18
20
 
19
21
  function sleep(ms: number): Promise<void> {
20
22
  return new Promise((resolve) => setTimeout(resolve, ms))
21
23
  }
22
24
 
23
- async function fetchWithTimeout(
24
- input: RequestInfo | URL,
25
- init: RequestInit,
26
- timeoutMs: number,
27
- ): Promise<Response> {
28
- const controller = new AbortController()
29
- const timer = setTimeout(() => controller.abort(), timeoutMs)
30
- try {
31
- return await fetch(input, { ...init, signal: controller.signal })
32
- } finally {
33
- clearTimeout(timer)
34
- }
35
- }
36
-
37
25
  function isAbortError(err: unknown): boolean {
38
26
  if (!err || typeof err !== 'object') return false
39
27
  return (err as { name?: string }).name === 'AbortError'
@@ -0,0 +1,17 @@
1
+ const MIN_TIMEOUT_MS = 1_000
2
+
3
+ export async function fetchWithTimeout(
4
+ input: RequestInfo | URL,
5
+ init: RequestInit = {},
6
+ timeoutMs: number,
7
+ ): Promise<Response> {
8
+ const boundedTimeout = Math.max(MIN_TIMEOUT_MS, Math.trunc(timeoutMs))
9
+ const controller = new AbortController()
10
+ const timer = setTimeout(() => controller.abort(), boundedTimeout)
11
+
12
+ try {
13
+ return await fetch(input, { ...init, signal: controller.signal })
14
+ } finally {
15
+ clearTimeout(timer)
16
+ }
17
+ }
@@ -1,3 +1,5 @@
1
+ import { safeStorageGet, safeStorageSet } from '@/lib/safe-storage'
2
+
1
3
  let ctx: AudioContext | null = null
2
4
 
3
5
  function ensureCtx(): AudioContext | null {
@@ -48,11 +50,9 @@ export function playError() {
48
50
  const LS_KEY = 'sc_sound_notifications'
49
51
 
50
52
  export function getSoundEnabled(): boolean {
51
- if (typeof window === 'undefined') return false
52
- return localStorage.getItem(LS_KEY) === '1'
53
+ return safeStorageGet(LS_KEY) === '1'
53
54
  }
54
55
 
55
56
  export function setSoundEnabled(v: boolean) {
56
- if (typeof window === 'undefined') return
57
- localStorage.setItem(LS_KEY, v ? '1' : '0')
57
+ safeStorageSet(LS_KEY, v ? '1' : '0')
58
58
  }
@@ -0,0 +1,42 @@
1
+ function canUseLocalStorage(): boolean {
2
+ return typeof window !== 'undefined' && !!window.localStorage
3
+ }
4
+
5
+ export function safeStorageGet(key: string): string | null {
6
+ if (!canUseLocalStorage()) return null
7
+ try {
8
+ return window.localStorage.getItem(key)
9
+ } catch {
10
+ return null
11
+ }
12
+ }
13
+
14
+ export function safeStorageSet(key: string, value: string): boolean {
15
+ if (!canUseLocalStorage()) return false
16
+ try {
17
+ window.localStorage.setItem(key, value)
18
+ return true
19
+ } catch {
20
+ return false
21
+ }
22
+ }
23
+
24
+ export function safeStorageRemove(key: string): boolean {
25
+ if (!canUseLocalStorage()) return false
26
+ try {
27
+ window.localStorage.removeItem(key)
28
+ return true
29
+ } catch {
30
+ return false
31
+ }
32
+ }
33
+
34
+ export function safeStorageGetJson<T>(key: string, fallback: T): T {
35
+ const raw = safeStorageGet(key)
36
+ if (!raw) return fallback
37
+ try {
38
+ return JSON.parse(raw) as T
39
+ } catch {
40
+ return fallback
41
+ }
42
+ }