@swarmclawai/swarmclaw 0.4.5 → 0.5.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 (113) hide show
  1. package/README.md +8 -2
  2. package/bin/server-cmd.js +28 -19
  3. package/next.config.ts +5 -0
  4. package/package.json +2 -1
  5. package/src/app/api/agents/[id]/route.ts +23 -5
  6. package/src/app/api/agents/trash/route.ts +44 -0
  7. package/src/app/api/connectors/[id]/route.ts +7 -4
  8. package/src/app/api/connectors/[id]/webhook/route.ts +6 -2
  9. package/src/app/api/openclaw/agent-files/route.ts +57 -0
  10. package/src/app/api/openclaw/approvals/route.ts +46 -0
  11. package/src/app/api/openclaw/config-sync/route.ts +33 -0
  12. package/src/app/api/openclaw/cron/route.ts +52 -0
  13. package/src/app/api/openclaw/directory/route.ts +4 -3
  14. package/src/app/api/openclaw/discover/route.ts +3 -2
  15. package/src/app/api/openclaw/dotenv-keys/route.ts +18 -0
  16. package/src/app/api/openclaw/exec-config/route.ts +41 -0
  17. package/src/app/api/openclaw/gateway/route.ts +72 -0
  18. package/src/app/api/openclaw/history/route.ts +109 -0
  19. package/src/app/api/openclaw/media/route.ts +53 -0
  20. package/src/app/api/openclaw/models/route.ts +12 -0
  21. package/src/app/api/openclaw/permissions/route.ts +39 -0
  22. package/src/app/api/openclaw/sandbox-env/route.ts +69 -0
  23. package/src/app/api/openclaw/skills/install/route.ts +32 -0
  24. package/src/app/api/openclaw/skills/remove/route.ts +24 -0
  25. package/src/app/api/openclaw/skills/route.ts +82 -0
  26. package/src/app/api/openclaw/sync/route.ts +3 -2
  27. package/src/app/api/projects/[id]/route.ts +1 -1
  28. package/src/app/api/projects/route.ts +1 -1
  29. package/src/app/api/secrets/[id]/route.ts +1 -1
  30. package/src/app/api/sessions/[id]/edit-resend/route.ts +22 -0
  31. package/src/app/api/sessions/[id]/fork/route.ts +44 -0
  32. package/src/app/api/sessions/[id]/messages/route.ts +18 -1
  33. package/src/app/api/sessions/[id]/route.ts +12 -3
  34. package/src/app/api/sessions/route.ts +6 -2
  35. package/src/app/globals.css +14 -0
  36. package/src/app/layout.tsx +5 -20
  37. package/src/cli/index.js +33 -1
  38. package/src/cli/spec.js +40 -0
  39. package/src/components/agents/agent-avatar.tsx +45 -0
  40. package/src/components/agents/agent-card.tsx +19 -5
  41. package/src/components/agents/agent-chat-list.tsx +31 -24
  42. package/src/components/agents/agent-files-editor.tsx +185 -0
  43. package/src/components/agents/agent-list.tsx +82 -3
  44. package/src/components/agents/agent-sheet.tsx +31 -0
  45. package/src/components/agents/cron-job-form.tsx +137 -0
  46. package/src/components/agents/exec-config-panel.tsx +147 -0
  47. package/src/components/agents/inspector-panel.tsx +310 -0
  48. package/src/components/agents/openclaw-skills-panel.tsx +230 -0
  49. package/src/components/agents/permission-preset-selector.tsx +79 -0
  50. package/src/components/agents/personality-builder.tsx +111 -0
  51. package/src/components/agents/sandbox-env-panel.tsx +72 -0
  52. package/src/components/agents/skill-install-dialog.tsx +102 -0
  53. package/src/components/agents/trash-list.tsx +109 -0
  54. package/src/components/chat/chat-area.tsx +14 -2
  55. package/src/components/chat/chat-header.tsx +168 -4
  56. package/src/components/chat/chat-preview-panel.tsx +113 -0
  57. package/src/components/chat/exec-approval-card.tsx +89 -0
  58. package/src/components/chat/message-bubble.tsx +218 -36
  59. package/src/components/chat/message-list.tsx +135 -31
  60. package/src/components/chat/streaming-bubble.tsx +59 -10
  61. package/src/components/chat/suggestions-bar.tsx +74 -0
  62. package/src/components/chat/thinking-indicator.tsx +20 -6
  63. package/src/components/chat/tool-call-bubble.tsx +89 -16
  64. package/src/components/chat/tool-request-banner.tsx +20 -2
  65. package/src/components/chat/trace-block.tsx +103 -0
  66. package/src/components/projects/project-list.tsx +1 -0
  67. package/src/components/settings/gateway-connection-panel.tsx +278 -0
  68. package/src/components/shared/avatar.tsx +13 -2
  69. package/src/components/shared/settings/settings-page.tsx +1 -0
  70. package/src/components/tasks/task-board.tsx +1 -1
  71. package/src/components/tasks/task-sheet.tsx +12 -12
  72. package/src/hooks/use-continuous-speech.ts +42 -5
  73. package/src/hooks/use-openclaw-gateway.ts +63 -0
  74. package/src/lib/notification-sounds.ts +58 -0
  75. package/src/lib/personality-parser.ts +97 -0
  76. package/src/lib/providers/openclaw.ts +17 -2
  77. package/src/lib/runtime-loop.ts +2 -2
  78. package/src/lib/server/chat-execution.ts +44 -2
  79. package/src/lib/server/connectors/bluebubbles.test.ts +17 -8
  80. package/src/lib/server/connectors/bluebubbles.ts +5 -2
  81. package/src/lib/server/connectors/googlechat.ts +14 -10
  82. package/src/lib/server/connectors/manager.ts +37 -15
  83. package/src/lib/server/connectors/openclaw.ts +1 -0
  84. package/src/lib/server/daemon-state.ts +11 -0
  85. package/src/lib/server/main-agent-loop.ts +2 -3
  86. package/src/lib/server/main-session.ts +21 -0
  87. package/src/lib/server/openclaw-config-sync.ts +107 -0
  88. package/src/lib/server/openclaw-exec-config.ts +52 -0
  89. package/src/lib/server/openclaw-gateway.ts +291 -0
  90. package/src/lib/server/openclaw-history-merge.ts +36 -0
  91. package/src/lib/server/openclaw-models.ts +56 -0
  92. package/src/lib/server/openclaw-permission-presets.ts +64 -0
  93. package/src/lib/server/openclaw-sync.ts +11 -10
  94. package/src/lib/server/queue.ts +2 -1
  95. package/src/lib/server/session-tools/connector.ts +4 -4
  96. package/src/lib/server/session-tools/delegate.ts +19 -3
  97. package/src/lib/server/session-tools/file.ts +20 -20
  98. package/src/lib/server/session-tools/index.ts +2 -2
  99. package/src/lib/server/session-tools/openclaw-nodes.ts +6 -6
  100. package/src/lib/server/session-tools/sandbox.ts +2 -2
  101. package/src/lib/server/session-tools/search-providers.ts +13 -6
  102. package/src/lib/server/session-tools/session-tools-wiring.test.ts +2 -2
  103. package/src/lib/server/session-tools/shell.ts +1 -1
  104. package/src/lib/server/session-tools/web.ts +8 -8
  105. package/src/lib/server/storage.ts +62 -11
  106. package/src/lib/server/stream-agent-chat.ts +24 -4
  107. package/src/lib/server/suggestions.ts +20 -0
  108. package/src/lib/server/ws-hub.ts +14 -0
  109. package/src/stores/use-app-store.ts +53 -1
  110. package/src/stores/use-approval-store.ts +78 -0
  111. package/src/stores/use-chat-store.ts +153 -5
  112. package/src/types/index.ts +127 -1
  113. package/tsconfig.json +13 -4
@@ -1,22 +1,72 @@
1
1
  'use client'
2
2
 
3
- import { useMemo } from 'react'
3
+ import { useMemo, useState } from 'react'
4
4
  import ReactMarkdown from 'react-markdown'
5
5
  import remarkGfm from 'remark-gfm'
6
6
  import rehypeHighlight from 'rehype-highlight'
7
7
  import { AiAvatar } from '@/components/shared/avatar'
8
+ import { AgentAvatar } from '@/components/agents/agent-avatar'
8
9
  import { CodeBlock } from './code-block'
9
10
  import { ToolCallBubble } from './tool-call-bubble'
10
- import { useChatStore } from '@/stores/use-chat-store'
11
+ import { useChatStore, type ToolEvent } from '@/stores/use-chat-store'
12
+
13
+ function ToolEventsSection({ toolEvents }: { toolEvents: ToolEvent[] }) {
14
+ const [expanded, setExpanded] = useState(false)
15
+ const shouldCollapse = toolEvents.length > 2
16
+ const latestTool = toolEvents[toolEvents.length - 1]
17
+
18
+ if (shouldCollapse && !expanded) {
19
+ return (
20
+ <div className="max-w-[85%] md:max-w-[72%] flex flex-col gap-2 mb-2">
21
+ <button
22
+ type="button"
23
+ onClick={() => setExpanded(true)}
24
+ className="self-start flex items-center gap-2 px-3 py-1.5 rounded-[8px] bg-white/[0.04] hover:bg-white/[0.07] border border-white/[0.06] cursor-pointer transition-colors"
25
+ >
26
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-text-3/60">
27
+ <polyline points="6 9 12 15 18 9" />
28
+ </svg>
29
+ <span className="text-[11px] text-text-3 font-mono">
30
+ {toolEvents.length} tool calls
31
+ </span>
32
+ <span className="text-[10px] text-text-3/50">
33
+ latest: {latestTool?.name || 'unknown'}
34
+ </span>
35
+ </button>
36
+ </div>
37
+ )
38
+ }
39
+
40
+ return (
41
+ <div className="max-w-[85%] md:max-w-[72%] flex flex-col gap-2 mb-2">
42
+ {shouldCollapse && (
43
+ <button
44
+ type="button"
45
+ onClick={() => setExpanded(false)}
46
+ className="self-start px-2.5 py-1 rounded-[8px] bg-white/[0.04] hover:bg-white/[0.07] text-[11px] text-text-3 border border-white/[0.06] cursor-pointer transition-colors"
47
+ >
48
+ Collapse tool calls
49
+ </button>
50
+ )}
51
+ {toolEvents.map((event) => (
52
+ <ToolCallBubble key={event.id} event={event} />
53
+ ))}
54
+ </div>
55
+ )
56
+ }
11
57
 
12
58
  interface Props {
13
59
  text: string
14
60
  assistantName?: string
61
+ agentAvatarSeed?: string
62
+ agentName?: string
15
63
  }
16
64
 
17
- export function StreamingBubble({ text, assistantName }: Props) {
65
+ export function StreamingBubble({ text, assistantName, agentAvatarSeed, agentName }: Props) {
18
66
  const rendered = useMemo(() => text, [text])
19
67
  const toolEvents = useChatStore((s) => s.toolEvents)
68
+ const streamPhase = useChatStore((s) => s.streamPhase)
69
+ const streamToolName = useChatStore((s) => s.streamToolName)
20
70
 
21
71
  return (
22
72
  <div
@@ -24,18 +74,17 @@ export function StreamingBubble({ text, assistantName }: Props) {
24
74
  style={{ animation: 'msg-in-left 0.35s cubic-bezier(0.16, 1, 0.3, 1)' }}
25
75
  >
26
76
  <div className="flex items-center gap-2.5 mb-2 px-1">
27
- <AiAvatar size="sm" />
77
+ {agentName ? <AgentAvatar seed={agentAvatarSeed || null} name={agentName} size={36} /> : <AiAvatar size="sm" mood={streamPhase === 'tool' ? 'tool' : 'thinking'} />}
28
78
  <span className="text-[12px] font-600 text-text-3">{assistantName || 'Claude'}</span>
29
79
  <span className="w-2 h-2 rounded-full bg-accent-bright" style={{ animation: 'pulse 1.5s ease infinite' }} />
80
+ {streamPhase === 'tool' && streamToolName && (
81
+ <span className="text-[10px] text-text-3/50 font-mono">Using {streamToolName}...</span>
82
+ )}
30
83
  </div>
31
84
 
32
- {/* Tool call events */}
85
+ {/* Tool call events (collapsible when > 2) */}
33
86
  {toolEvents.length > 0 && (
34
- <div className="max-w-[85%] md:max-w-[72%] flex flex-col gap-2 mb-2">
35
- {toolEvents.map((event) => (
36
- <ToolCallBubble key={event.id} event={event} />
37
- ))}
38
- </div>
87
+ <ToolEventsSection toolEvents={toolEvents} />
39
88
  )}
40
89
 
41
90
  {rendered && (
@@ -0,0 +1,74 @@
1
+ 'use client'
2
+
3
+ import type { Message } from '@/types'
4
+
5
+ interface Props {
6
+ lastMessage: Message | null
7
+ onSend: (text: string) => void
8
+ }
9
+
10
+ function getSuggestions(msg: Message | null): string[] {
11
+ if (!msg?.text) return ['Continue', 'Tell me more']
12
+ const text = msg.text
13
+
14
+ // Error patterns
15
+ if (/error|exception|failed|traceback|panic|ECONNREFUSED|ETIMEDOUT/i.test(text)) {
16
+ return ['Can you fix this?', 'Try an alternative approach', 'Explain the error']
17
+ }
18
+
19
+ // Code blocks present
20
+ if (/```[\s\S]*```/.test(text)) {
21
+ return ['Explain this code', 'Write tests for this', 'Any improvements?']
22
+ }
23
+
24
+ // File mentions
25
+ if (/`\/[\w./-]+\.\w+`/.test(text) || /\b(created|modified|updated|wrote|saved)\b.*\b(file|files)\b/i.test(text)) {
26
+ return ['Show me the file', 'Make changes to it', 'What else needs updating?']
27
+ }
28
+
29
+ // Task completion signals
30
+ if (/\b(done|complete|finished|ready|all set|successfully)\b/i.test(text)) {
31
+ return ["What's next?", 'Summarize what was done', 'Any remaining issues?']
32
+ }
33
+
34
+ // Question asked by assistant
35
+ if (/\?\s*$/.test(text.trim())) {
36
+ return ['Yes, go ahead', 'No, try a different approach', 'Tell me more about the options']
37
+ }
38
+
39
+ // List/steps presented
40
+ if (/^\s*(\d+\.|[-*])\s/m.test(text)) {
41
+ return ['Start with the first step', 'Can you elaborate?', 'Any alternatives?']
42
+ }
43
+
44
+ return ['Continue', 'Tell me more', 'Can you explain further?']
45
+ }
46
+
47
+ export function SuggestionsBar({ lastMessage, onSend }: Props) {
48
+ const suggestions = lastMessage?.suggestions?.length === 3
49
+ ? lastMessage.suggestions
50
+ : getSuggestions(lastMessage)
51
+
52
+ if (!suggestions.length) return null
53
+
54
+ return (
55
+ <div
56
+ className="flex flex-wrap gap-2 px-1 pt-2"
57
+ style={{ animation: 'fade-in 0.3s cubic-bezier(0.16, 1, 0.3, 1)' }}
58
+ >
59
+ {suggestions.map((text) => (
60
+ <button
61
+ key={text}
62
+ type="button"
63
+ onClick={() => onSend(text)}
64
+ className="rounded-full px-3.5 py-1.5 text-[12px] font-500 border border-white/[0.06] bg-white/[0.03]
65
+ text-text-3 hover:text-text-2 hover:bg-white/[0.06] hover:border-white/[0.10]
66
+ cursor-pointer transition-all active:scale-[0.97]"
67
+ style={{ fontFamily: 'inherit' }}
68
+ >
69
+ {text}
70
+ </button>
71
+ ))}
72
+ </div>
73
+ )
74
+ }
@@ -1,24 +1,38 @@
1
1
  'use client'
2
2
 
3
3
  import { AiAvatar } from '@/components/shared/avatar'
4
+ import { AgentAvatar } from '@/components/agents/agent-avatar'
5
+ import { useChatStore } from '@/stores/use-chat-store'
4
6
 
5
7
  interface Props {
6
8
  assistantName?: string
9
+ agentAvatarSeed?: string
10
+ agentName?: string
7
11
  }
8
12
 
9
- export function ThinkingIndicator({ assistantName }: Props) {
13
+ export function ThinkingIndicator({ assistantName, agentAvatarSeed, agentName }: Props) {
14
+ const streamPhase = useChatStore((s) => s.streamPhase)
15
+ const streamToolName = useChatStore((s) => s.streamToolName)
16
+
17
+ const statusText = streamPhase === 'tool' && streamToolName
18
+ ? `Using ${streamToolName}...`
19
+ : 'Thinking...'
20
+
10
21
  return (
11
22
  <div className="flex flex-col items-start"
12
23
  style={{ animation: 'msg-in-left 0.35s cubic-bezier(0.16, 1, 0.3, 1)' }}>
13
24
  <div className="flex items-center gap-2.5 mb-2 px-1">
14
- <AiAvatar size="sm" />
25
+ {agentName ? <AgentAvatar seed={agentAvatarSeed || null} name={agentName} size={36} /> : <AiAvatar size="sm" mood={streamPhase === 'tool' ? 'tool' : 'thinking'} />}
15
26
  <span className="text-[12px] font-600 text-text-3">{assistantName || 'Claude'}</span>
16
27
  </div>
17
28
  <div className="bubble-ai px-6 py-5">
18
- <div className="flex gap-2">
19
- <span className="w-[6px] h-[6px] rounded-full bg-accent-bright/60" style={{ animation: 'dot-bounce 1.2s ease-in-out infinite' }} />
20
- <span className="w-[6px] h-[6px] rounded-full bg-accent-bright/60" style={{ animation: 'dot-bounce 1.2s ease-in-out infinite 0.15s' }} />
21
- <span className="w-[6px] h-[6px] rounded-full bg-accent-bright/60" style={{ animation: 'dot-bounce 1.2s ease-in-out infinite 0.3s' }} />
29
+ <div className="flex items-center gap-3">
30
+ <div className="flex gap-2">
31
+ <span className="w-[6px] h-[6px] rounded-full bg-accent-bright/60" style={{ animation: 'dot-bounce 1.2s ease-in-out infinite' }} />
32
+ <span className="w-[6px] h-[6px] rounded-full bg-accent-bright/60" style={{ animation: 'dot-bounce 1.2s ease-in-out infinite 0.15s' }} />
33
+ <span className="w-[6px] h-[6px] rounded-full bg-accent-bright/60" style={{ animation: 'dot-bounce 1.2s ease-in-out infinite 0.3s' }} />
34
+ </div>
35
+ <span className="text-[12px] text-text-3/60 font-mono">{statusText}</span>
22
36
  </div>
23
37
  </div>
24
38
  </div>
@@ -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 { useChatStore } from '@/stores/use-chat-store'
5
6
  import { useAppStore } from '@/stores/use-app-store'
6
7
 
7
8
  const TOOL_COLORS: Record<string, string> = {
@@ -251,6 +252,61 @@ function extractMedia(output: string): { images: string[]; videos: string[]; pdf
251
252
  return { images, videos, pdfs, files, cleanText }
252
253
  }
253
254
 
255
+ import type { AppSettings } from '@/types'
256
+
257
+ /** Settings keys that can be quick-fixed from error output */
258
+ const TIMEOUT_SETTINGS: Array<{ pattern: RegExp; settingKey: keyof AppSettings; label: string; increment: number }> = [
259
+ { pattern: /Claude Code CLI timed out/i, settingKey: 'claudeCodeTimeoutSec', label: 'Claude Code Timeout', increment: 600 },
260
+ { pattern: /Codex CLI timed out|OpenCode CLI timed out/i, settingKey: 'cliProcessTimeoutSec', label: 'CLI Process Timeout', increment: 600 },
261
+ { pattern: /command timed out|shell.*timed out/i, settingKey: 'shellCommandTimeoutSec', label: 'Shell Timeout', increment: 60 },
262
+ ]
263
+
264
+ /** Inline quick-fix button for timeout errors */
265
+ function TimeoutQuickFix({ event }: { event: ToolEvent }) {
266
+ const [applied, setApplied] = useState(false)
267
+ if (event.status !== 'error' || !event.output) return null
268
+
269
+ const match = TIMEOUT_SETTINGS.find((s) => s.pattern.test(event.output || ''))
270
+ if (!match) return null
271
+
272
+ const handleIncrease = async (e: React.MouseEvent) => {
273
+ e.stopPropagation()
274
+ const store = useAppStore.getState()
275
+ const current = (store.appSettings[match.settingKey] as number) || 0
276
+ const newValue = current + match.increment
277
+ await store.updateSettings({ [match.settingKey]: newValue })
278
+ setApplied(true)
279
+ }
280
+
281
+ if (applied) {
282
+ const store = useAppStore.getState()
283
+ const val = store.appSettings[match.settingKey] as number
284
+ return (
285
+ <div className="flex items-center gap-2 mt-2 text-[12px] text-emerald-400 font-500">
286
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><polyline points="20 6 9 17 4 12" /></svg>
287
+ {match.label} increased to {val}s
288
+ </div>
289
+ )
290
+ }
291
+
292
+ return (
293
+ <button
294
+ type="button"
295
+ onClick={handleIncrease}
296
+ className="mt-2 flex items-center gap-1.5 px-3 py-1.5 rounded-[8px] text-[12px] font-600
297
+ bg-amber-500/10 border border-amber-500/20 text-amber-400 hover:bg-amber-500/20
298
+ cursor-pointer transition-all"
299
+ style={{ fontFamily: 'inherit' }}
300
+ >
301
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
302
+ <circle cx="12" cy="12" r="10" />
303
+ <polyline points="12 6 12 12 16 14" />
304
+ </svg>
305
+ Increase {match.label}
306
+ </button>
307
+ )
308
+ }
309
+
254
310
  export function ToolCallBubble({ event }: { event: ToolEvent }) {
255
311
  const [expanded, setExpanded] = useState(false)
256
312
  const [imgExpanded, setImgExpanded] = useState(false)
@@ -338,7 +394,7 @@ export function ToolCallBubble({ event }: { event: ToolEvent }) {
338
394
  role="link"
339
395
  tabIndex={0}
340
396
  onClick={handleAgentClick}
341
- onKeyDown={(e) => e.key === 'Enter' && handleAgentClick(e as any)}
397
+ onKeyDown={(e) => e.key === 'Enter' && handleAgentClick(e as unknown as React.MouseEvent)}
342
398
  className="text-accent-bright hover:underline cursor-pointer font-600"
343
399
  >
344
400
  {delegationInfo.agentName}
@@ -383,6 +439,7 @@ export function ToolCallBubble({ event }: { event: ToolEvent }) {
383
439
  {formattedCleanOutput}
384
440
  </pre>
385
441
  )}
442
+ <TimeoutQuickFix event={event} />
386
443
  </>
387
444
  )}
388
445
  </div>
@@ -397,23 +454,39 @@ export function ToolCallBubble({ event }: { event: ToolEvent }) {
397
454
  <img
398
455
  src={src}
399
456
  alt={`Screenshot ${i + 1}`}
457
+ loading="lazy"
400
458
  className={`rounded-[10px] border border-white/10 cursor-pointer transition-all duration-200 hover:border-white/25 ${imgExpanded ? 'max-w-full' : 'max-w-[400px]'}`}
401
459
  onClick={(e) => { e.stopPropagation(); setImgExpanded(!imgExpanded) }}
402
460
  onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
403
461
  />
404
- <a
405
- href={src}
406
- download
407
- onClick={(e) => e.stopPropagation()}
408
- className="absolute top-2 right-2 opacity-0 group-hover/img:opacity-100 transition-opacity bg-black/60 backdrop-blur-sm rounded-[8px] p-1.5 hover:bg-black/80"
409
- title="Download"
410
- >
411
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round">
412
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
413
- <polyline points="7 10 12 15 17 10" />
414
- <line x1="12" y1="15" x2="12" y2="3" />
415
- </svg>
416
- </a>
462
+ <div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover/img:opacity-100 transition-opacity">
463
+ <button
464
+ onClick={(e) => {
465
+ e.stopPropagation()
466
+ useChatStore.getState().setPreviewContent({ type: 'image', url: src, title: `${label} Screenshot` })
467
+ }}
468
+ className="bg-black/60 backdrop-blur-sm rounded-[8px] p-1.5 hover:bg-black/80 border-none cursor-pointer"
469
+ title="Open in side panel"
470
+ >
471
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round">
472
+ <rect x="3" y="3" width="18" height="18" rx="2" />
473
+ <line x1="12" y1="3" x2="12" y2="21" />
474
+ </svg>
475
+ </button>
476
+ <a
477
+ href={src}
478
+ download
479
+ onClick={(e) => e.stopPropagation()}
480
+ className="bg-black/60 backdrop-blur-sm rounded-[8px] p-1.5 hover:bg-black/80"
481
+ title="Download"
482
+ >
483
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round">
484
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
485
+ <polyline points="7 10 12 15 17 10" />
486
+ <line x1="12" y1="15" x2="12" y2="3" />
487
+ </svg>
488
+ </a>
489
+ </div>
417
490
  </div>
418
491
  ))}
419
492
  </div>
@@ -423,7 +496,7 @@ export function ToolCallBubble({ event }: { event: ToolEvent }) {
423
496
  {media.videos.length > 0 && (
424
497
  <div className="mt-2 flex flex-col gap-2">
425
498
  {media.videos.map((src, i) => (
426
- <video key={i} src={src} controls playsInline className="max-w-full rounded-[10px] border border-white/10" />
499
+ <video key={i} src={src} controls playsInline preload="none" className="max-w-full rounded-[10px] border border-white/10" />
427
500
  ))}
428
501
  </div>
429
502
  )}
@@ -433,7 +506,7 @@ export function ToolCallBubble({ event }: { event: ToolEvent }) {
433
506
  <div className="mt-2 flex flex-col gap-2">
434
507
  {media.pdfs.map((file, i) => (
435
508
  <div key={i} className="rounded-[10px] border border-white/10 overflow-hidden">
436
- <iframe src={file.url} className="w-full h-[400px] bg-white" title={file.name} />
509
+ <iframe src={file.url} loading="lazy" className="w-full h-[400px] bg-white" title={file.name} />
437
510
  <a
438
511
  href={file.url}
439
512
  download
@@ -1,7 +1,8 @@
1
1
  'use client'
2
2
 
3
- import { useState } from 'react'
3
+ import { useState, useRef } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
+ import { useChatStore } from '@/stores/use-chat-store'
5
6
  import { api } from '@/lib/api-client'
6
7
  import { TOOL_LABELS } from '@/lib/tool-definitions'
7
8
 
@@ -15,6 +16,7 @@ export function ToolRequestBanner({ text, toolOutputs = [] }: Props) {
15
16
  const currentSessionId = useAppStore((s) => s.currentSessionId)
16
17
  const sessions = useAppStore((s) => s.sessions)
17
18
  const [granted, setGranted] = useState<Set<string>>(new Set())
19
+ const continueSentRef = useRef(false)
18
20
 
19
21
  const toolRequests: { toolId: string; reason: string }[] = []
20
22
  const seen = new Set<string>()
@@ -53,7 +55,23 @@ export function ToolRequestBanner({ text, toolOutputs = [] }: Props) {
53
55
  const updated = [...currentTools, toolId]
54
56
  await api('PUT', `/sessions/${sid}`, { tools: updated })
55
57
  await loadSessions()
56
- setGranted((prev) => new Set(prev).add(toolId))
58
+ const newGranted = new Set(granted).add(toolId)
59
+ setGranted(newGranted)
60
+
61
+ // Auto-continue: once all requested tools are granted, send a follow-up message
62
+ const allGranted = toolRequests.every(
63
+ (r) => newGranted.has(r.toolId) || updated.includes(r.toolId),
64
+ )
65
+ if (allGranted && !continueSentRef.current) {
66
+ continueSentRef.current = true
67
+ // Small delay to let the session update propagate
68
+ setTimeout(() => {
69
+ const { streaming, sendMessage } = useChatStore.getState()
70
+ if (!streaming) {
71
+ sendMessage('Continue')
72
+ }
73
+ }, 300)
74
+ }
57
75
  }
58
76
 
59
77
  return (
@@ -0,0 +1,103 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import type { ChatTraceBlock } from '@/types'
5
+
6
+ interface Props {
7
+ trace: ChatTraceBlock
8
+ }
9
+
10
+ export function TraceBlock({ trace }: Props) {
11
+ const [collapsed, setCollapsed] = useState(trace.collapsed !== false)
12
+
13
+ const bgColor = trace.type === 'thinking'
14
+ ? 'bg-purple-500/[0.04] border-purple-500/10'
15
+ : trace.type === 'tool-call'
16
+ ? 'bg-sky-500/[0.04] border-sky-500/10'
17
+ : 'bg-emerald-500/[0.04] border-emerald-500/10'
18
+
19
+ const labelColor = trace.type === 'thinking'
20
+ ? 'text-purple-400/70'
21
+ : trace.type === 'tool-call'
22
+ ? 'text-sky-400/70'
23
+ : 'text-emerald-400/70'
24
+
25
+ const icon = trace.type === 'thinking'
26
+ ? '...'
27
+ : trace.type === 'tool-call'
28
+ ? '>'
29
+ : '<'
30
+
31
+ return (
32
+ <div className={`my-1 rounded-[8px] border ${bgColor} overflow-hidden`}>
33
+ <button
34
+ onClick={() => setCollapsed(!collapsed)}
35
+ className={`w-full flex items-center gap-2 px-3 py-1.5 text-left cursor-pointer border-none bg-transparent transition-colors hover:bg-white/[0.02] ${labelColor}`}
36
+ style={{ fontFamily: 'inherit' }}
37
+ >
38
+ <span className="font-mono text-[10px] w-4 shrink-0">{collapsed ? '+' : '-'}</span>
39
+ <span className="font-mono text-[10px] shrink-0">{icon}</span>
40
+ <span className="text-[11px] font-600 truncate">
41
+ {trace.label || trace.type.replace('-', ' ')}
42
+ </span>
43
+ </button>
44
+ {!collapsed && (
45
+ <div className="px-3 pb-2">
46
+ <pre className={`text-[11px] leading-relaxed whitespace-pre-wrap break-words m-0 ${
47
+ trace.type === 'thinking'
48
+ ? 'text-text-3/60 italic'
49
+ : 'text-text-3/70 font-mono'
50
+ }`}>
51
+ {trace.content.length > 2000
52
+ ? trace.content.slice(0, 2000) + '\n... (truncated)'
53
+ : trace.content}
54
+ </pre>
55
+ </div>
56
+ )}
57
+ </div>
58
+ )
59
+ }
60
+
61
+ /** Parse message text with [[prefix]] markers into text and trace blocks */
62
+ export function parseTraceBlocks(text: string): Array<{ type: 'text'; content: string } | ChatTraceBlock> {
63
+ const blocks: Array<{ type: 'text'; content: string } | ChatTraceBlock> = []
64
+ const regex = /\[\[(thinking|tool|tool-result|trace|meta)\]\]([\s\S]*?)(?=\[\[(thinking|tool|tool-result|trace|meta)\]\]|$)/g
65
+
66
+ let lastEnd = 0
67
+ let match: RegExpExecArray | null
68
+
69
+ while ((match = regex.exec(text)) !== null) {
70
+ // Add any text before this match
71
+ if (match.index > lastEnd) {
72
+ const before = text.slice(lastEnd, match.index).trim()
73
+ if (before) blocks.push({ type: 'text', content: before })
74
+ }
75
+
76
+ const prefix = match[1]
77
+ const content = match[2].trim()
78
+ if (content) {
79
+ if (prefix === 'thinking' || prefix === 'trace') {
80
+ blocks.push({ type: 'thinking', content, collapsed: true })
81
+ } else if (prefix === 'tool') {
82
+ const firstLine = content.split('\n')[0] || ''
83
+ blocks.push({ type: 'tool-call', content, label: firstLine.slice(0, 60), collapsed: true })
84
+ } else if (prefix === 'tool-result') {
85
+ blocks.push({ type: 'tool-result', content, collapsed: true })
86
+ }
87
+ // meta is ignored
88
+ }
89
+
90
+ lastEnd = match.index + match[0].length
91
+ }
92
+
93
+ // Add remaining text
94
+ if (lastEnd === 0) {
95
+ // No trace markers found
96
+ if (text.trim()) blocks.push({ type: 'text', content: text })
97
+ } else if (lastEnd < text.length) {
98
+ const remaining = text.slice(lastEnd).trim()
99
+ if (remaining) blocks.push({ type: 'text', content: remaining })
100
+ }
101
+
102
+ return blocks
103
+ }
@@ -14,6 +14,7 @@ export function ProjectList() {
14
14
  const setActiveProjectFilter = useAppStore((s) => s.setActiveProjectFilter)
15
15
  const [search, setSearch] = useState('')
16
16
 
17
+ // eslint-disable-next-line react-hooks/exhaustive-deps
17
18
  useEffect(() => { loadProjects() }, [])
18
19
 
19
20
  const filtered = useMemo(() => {