@swarmclawai/swarmclaw 0.6.3 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "Self-hosted AI agent orchestration dashboard — manage LLM providers, orchestrate agent swarms, schedule tasks, and bridge agents to chat platforms.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/app/page.tsx CHANGED
@@ -5,6 +5,7 @@ import { useAppStore } from '@/stores/use-app-store'
5
5
  import { initAudioContext } from '@/lib/tts'
6
6
  import { getStoredAccessKey, clearStoredAccessKey, api } from '@/lib/api-client'
7
7
  import { connectWs, disconnectWs } from '@/lib/ws-client'
8
+ import { fetchWithTimeout } from '@/lib/fetch-timeout'
8
9
  import { useWs } from '@/hooks/use-ws'
9
10
  import { AccessKeyGate } from '@/components/auth/access-key-gate'
10
11
  import { UserPicker } from '@/components/auth/user-picker'
@@ -12,6 +13,8 @@ import { SetupWizard } from '@/components/auth/setup-wizard'
12
13
  import { AppLayout } from '@/components/layout/app-layout'
13
14
  import { useViewRouter } from '@/hooks/use-view-router'
14
15
 
16
+ const AUTH_CHECK_TIMEOUT_MS = 8_000
17
+
15
18
  function FullScreenLoader() {
16
19
  return (
17
20
  <div className="h-full flex flex-col items-center justify-center bg-bg overflow-hidden select-none">
@@ -158,11 +161,11 @@ export default function Home() {
158
161
  }
159
162
 
160
163
  try {
161
- const res = await fetch('/api/auth', {
164
+ const res = await fetchWithTimeout('/api/auth', {
162
165
  method: 'POST',
163
166
  headers: { 'Content-Type': 'application/json' },
164
167
  body: JSON.stringify({ key }),
165
- })
168
+ }, AUTH_CHECK_TIMEOUT_MS)
166
169
  if (res.ok) {
167
170
  setAuthenticated(true)
168
171
  } else {
@@ -171,8 +174,9 @@ export default function Home() {
171
174
  }
172
175
  } catch {
173
176
  setAuthenticated(true)
177
+ } finally {
178
+ setAuthChecked(true)
174
179
  }
175
- setAuthChecked(true)
176
180
  }, [])
177
181
 
178
182
  // After auth, try to restore username from server settings
@@ -2,11 +2,14 @@
2
2
 
3
3
  import { useState, useEffect } from 'react'
4
4
  import { setStoredAccessKey } from '@/lib/api-client'
5
+ import { fetchWithTimeout } from '@/lib/fetch-timeout'
5
6
 
6
7
  interface AccessKeyGateProps {
7
8
  onAuthenticated: () => void
8
9
  }
9
10
 
11
+ const AUTH_CHECK_TIMEOUT_MS = 8_000
12
+
10
13
  export function AccessKeyGate({ onAuthenticated }: AccessKeyGateProps) {
11
14
  const [key, setKey] = useState('')
12
15
  const [error, setError] = useState('')
@@ -19,16 +22,22 @@ export function AccessKeyGate({ onAuthenticated }: AccessKeyGateProps) {
19
22
  const [copied, setCopied] = useState(false)
20
23
 
21
24
  useEffect(() => {
22
- fetch('/api/auth')
23
- .then((r) => r.json())
24
- .then((data) => {
25
- if (data.firstTime && data.key) {
25
+ let cancelled = false
26
+ ;(async () => {
27
+ try {
28
+ const res = await fetchWithTimeout('/api/auth', {}, AUTH_CHECK_TIMEOUT_MS)
29
+ const data = await res.json().catch(() => ({}))
30
+ if (!cancelled && data.firstTime && data.key) {
26
31
  setFirstTime(true)
27
32
  setGeneratedKey(data.key)
28
33
  }
29
- })
30
- .catch((err) => console.error('Auth check failed:', err))
31
- .finally(() => setChecking(false))
34
+ } catch (err) {
35
+ console.error('Auth check failed:', err)
36
+ } finally {
37
+ if (!cancelled) setChecking(false)
38
+ }
39
+ })()
40
+ return () => { cancelled = true }
32
41
  }, [])
33
42
 
34
43
  const handleCopyKey = async () => {
@@ -44,14 +53,16 @@ export function AccessKeyGate({ onAuthenticated }: AccessKeyGateProps) {
44
53
  const handleClaimKey = async () => {
45
54
  setLoading(true)
46
55
  try {
47
- const res = await fetch('/api/auth', {
56
+ const res = await fetchWithTimeout('/api/auth', {
48
57
  method: 'POST',
49
58
  headers: { 'Content-Type': 'application/json' },
50
59
  body: JSON.stringify({ key: generatedKey }),
51
- })
60
+ }, AUTH_CHECK_TIMEOUT_MS)
52
61
  if (res.ok) {
53
62
  setStoredAccessKey(generatedKey)
54
63
  onAuthenticated()
64
+ } else {
65
+ setError('Invalid access key')
55
66
  }
56
67
  } catch {
57
68
  setError('Connection failed')
@@ -69,11 +80,11 @@ export function AccessKeyGate({ onAuthenticated }: AccessKeyGateProps) {
69
80
  setError('')
70
81
 
71
82
  try {
72
- const res = await fetch('/api/auth', {
83
+ const res = await fetchWithTimeout('/api/auth', {
73
84
  method: 'POST',
74
85
  headers: { 'Content-Type': 'application/json' },
75
86
  body: JSON.stringify({ key: trimmed }),
76
- })
87
+ }, AUTH_CHECK_TIMEOUT_MS)
77
88
  if (res.ok) {
78
89
  setStoredAccessKey(trimmed)
79
90
  onAuthenticated()
@@ -90,6 +90,12 @@ const STATUS_COLORS: Record<string, string> = {
90
90
  blocked: '#EF4444',
91
91
  }
92
92
 
93
+ function isGeneratedBrowserScreenshot(url: string): boolean {
94
+ const match = url.match(/\/api\/uploads\/([^/?#]+)/)
95
+ if (!match?.[1]) return false
96
+ return /^(browser|screenshot)-\d+\./i.test(match[1])
97
+ }
98
+
93
99
  // AttachmentChip, parseAttachmentUrl, regex constants, and FILE_TYPE_COLORS
94
100
  // are now imported from @/components/shared/attachment-chip
95
101
 
@@ -162,6 +168,7 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
162
168
  // Collect URLs from the visible (last) tool event to avoid showing duplicates
163
169
  const lastOutput = toolEvents[toolEvents.length - 1]?.output || ''
164
170
  const visibleMedia = extractMedia(lastOutput)
171
+ const hasNamedVisibleImage = visibleMedia.images.some((url) => !isGeneratedBrowserScreenshot(url))
165
172
  const seen = new Set<string>([
166
173
  ...visibleMedia.images,
167
174
  ...visibleMedia.videos,
@@ -175,7 +182,10 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
175
182
  for (const ev of toolEvents.slice(0, -1)) {
176
183
  if (!ev.output) continue
177
184
  const m = extractMedia(ev.output)
178
- for (const url of m.images) { if (!seen.has(url)) { seen.add(url); images.push(url) } }
185
+ for (const url of m.images) {
186
+ if (hasNamedVisibleImage && isGeneratedBrowserScreenshot(url)) continue
187
+ if (!seen.has(url)) { seen.add(url); images.push(url) }
188
+ }
179
189
  for (const url of m.videos) { if (!seen.has(url)) { seen.add(url); videos.push(url) } }
180
190
  for (const p of m.pdfs) { if (!seen.has(p.url)) { seen.add(p.url); pdfs.push(p) } }
181
191
  for (const f of m.files) { if (!seen.has(f.url)) { seen.add(f.url); files.push(f) } }
@@ -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
  }}
@@ -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
+ }
@@ -0,0 +1,29 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { dedupeScreenshotMarkdownLines } from './web-output'
4
+
5
+ describe('dedupeScreenshotMarkdownLines', () => {
6
+ it('prefers screenshot-* image when both browser-* and screenshot-* variants are present', () => {
7
+ const parts = [
8
+ '![Screenshot](/api/uploads/browser-1772498741525.png)',
9
+ '![Screenshot](/api/uploads/screenshot-1772498741526.png)',
10
+ 'Saved to: example_screenshot.png',
11
+ ]
12
+
13
+ const next = dedupeScreenshotMarkdownLines(parts)
14
+ assert.deepEqual(next, [
15
+ '![Screenshot](/api/uploads/screenshot-1772498741526.png)',
16
+ 'Saved to: example_screenshot.png',
17
+ ])
18
+ })
19
+
20
+ it('keeps single image output untouched', () => {
21
+ const parts = [
22
+ '![Screenshot](/api/uploads/browser-1772498741525.png)',
23
+ 'Saved to: example_screenshot.png',
24
+ ]
25
+
26
+ const next = dedupeScreenshotMarkdownLines(parts)
27
+ assert.deepEqual(next, parts)
28
+ })
29
+ })
@@ -0,0 +1,16 @@
1
+ export function dedupeScreenshotMarkdownLines(parts: string[]): string[] {
2
+ const imageLineRe = /^!\[[^\]]*]\(\/api\/uploads\/([^)]+)\)$/
3
+ const imageLines = parts
4
+ .map((line, index) => ({ line: line.trim(), index }))
5
+ .map((entry) => {
6
+ const match = entry.line.match(imageLineRe)
7
+ return match ? { ...entry, filename: match[1] } : null
8
+ })
9
+ .filter((entry): entry is { line: string; index: number; filename: string } => !!entry)
10
+
11
+ if (imageLines.length <= 1) return parts
12
+
13
+ const preferred = imageLines.find((entry) => !entry.filename.startsWith('browser-')) || imageLines[0]
14
+ const keepIndex = preferred.index
15
+ return parts.filter((_, index) => !imageLines.some((entry) => entry.index === index) || index === keepIndex)
16
+ }
@@ -8,6 +8,7 @@ import type { ToolBuildContext } from './context'
8
8
  import { spawnSync } from 'child_process'
9
9
  import { safePath, truncate, MAX_OUTPUT, findBinaryOnPath } from './context'
10
10
  import { getSearchProvider } from './search-providers'
11
+ import { dedupeScreenshotMarkdownLines } from './web-output'
11
12
 
12
13
  // ---------------------------------------------------------------------------
13
14
  // Search result compression — summarize verbose results before injecting into context
@@ -277,7 +278,8 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
277
278
  }
278
279
 
279
280
  if (Array.isArray(content)) {
280
- const parts: string[] = []
281
+ let parts: string[] = []
282
+ const isScreenshotTool = toolName === 'browser_take_screenshot'
281
283
  const contentHasBinaryImage = content.some((c) => c.type === 'image' && !!c.data)
282
284
  for (const c of content) {
283
285
  if (c.type === 'image' && c.data) {
@@ -334,6 +336,8 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
334
336
  }
335
337
  }
336
338
  }
339
+ if (isScreenshotTool) parts = dedupeScreenshotMarkdownLines(parts)
340
+
337
341
  if (savedPaths.length > 0) {
338
342
  const unique = Array.from(new Set(savedPaths))
339
343
  const rendered = unique.map((p) => path.relative(cwd, p) || '.').join(', ')
@@ -49,7 +49,11 @@ export function parsePath(pathname: string): { view: AppView; id: string | null
49
49
  if (pathname.startsWith(path + '/')) {
50
50
  const rest = pathname.slice(path.length + 1)
51
51
  if (rest && !rest.includes('/') && VIEWS_WITH_ID.has(view)) {
52
- return { view, id: decodeURIComponent(rest) }
52
+ try {
53
+ return { view, id: decodeURIComponent(rest) }
54
+ } catch {
55
+ return null
56
+ }
53
57
  }
54
58
  }
55
59
  }
@@ -7,6 +7,7 @@ import { fetchAgents } from '../lib/agents'
7
7
  import { fetchSchedules } from '../lib/schedules'
8
8
  import { fetchTasks } from '../lib/tasks'
9
9
  import { api } from '../lib/api-client'
10
+ import { safeStorageGet, safeStorageGetJson, safeStorageRemove, safeStorageSet } from '../lib/safe-storage'
10
11
 
11
12
  interface AppState {
12
13
  currentUser: string | null
@@ -209,14 +210,13 @@ export const useAppStore = create<AppState>((set, get) => ({
209
210
  currentUser: null,
210
211
  _hydrated: false,
211
212
  hydrate: () => {
212
- if (typeof window === 'undefined') return
213
- const user = localStorage.getItem('sc_user')
214
- const savedAgentId = localStorage.getItem('sc_agent')
213
+ const user = safeStorageGet('sc_user')
214
+ const savedAgentId = safeStorageGet('sc_agent')
215
215
  set({ currentUser: user, currentAgentId: savedAgentId, _hydrated: true })
216
216
  },
217
217
  setUser: (user) => {
218
- if (user) localStorage.setItem('sc_user', user)
219
- else localStorage.removeItem('sc_user')
218
+ if (user) safeStorageSet('sc_user', user)
219
+ else safeStorageRemove('sc_user')
220
220
  set({ currentUser: user })
221
221
  },
222
222
 
@@ -324,11 +324,11 @@ export const useAppStore = create<AppState>((set, get) => ({
324
324
  setCurrentAgent: async (id) => {
325
325
  if (!id) {
326
326
  set({ currentAgentId: null })
327
- if (typeof window !== 'undefined') localStorage.removeItem('sc_agent')
327
+ safeStorageRemove('sc_agent')
328
328
  return
329
329
  }
330
330
  set({ currentAgentId: id })
331
- if (typeof window !== 'undefined') localStorage.setItem('sc_agent', id)
331
+ safeStorageSet('sc_agent', id)
332
332
  try {
333
333
  const user = get().currentUser || 'default'
334
334
  const session = await api<Session>('POST', `/agents/${id}/thread`, { user })
@@ -628,13 +628,11 @@ export const useAppStore = create<AppState>((set, get) => ({
628
628
  },
629
629
 
630
630
  // Unread tracking
631
- lastReadTimestamps: typeof window !== 'undefined'
632
- ? (() => { try { return JSON.parse(localStorage.getItem('sc_last_read') || '{}') } catch { return {} } })()
633
- : {},
631
+ lastReadTimestamps: safeStorageGetJson<Record<string, number>>('sc_last_read', {}),
634
632
  markChatRead: (id) => {
635
633
  const ts = { ...get().lastReadTimestamps, [id]: Date.now() }
636
634
  set({ lastReadTimestamps: ts })
637
- try { localStorage.setItem('sc_last_read', JSON.stringify(ts)) } catch { /* ignore */ }
635
+ safeStorageSet('sc_last_read', JSON.stringify(ts))
638
636
  },
639
637
 
640
638
  // Notifications