@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.
- package/README.md +8 -2
- package/bin/server-cmd.js +28 -19
- package/next.config.ts +5 -0
- package/package.json +2 -1
- package/src/app/api/agents/[id]/route.ts +23 -5
- package/src/app/api/agents/trash/route.ts +44 -0
- package/src/app/api/connectors/[id]/route.ts +7 -4
- package/src/app/api/connectors/[id]/webhook/route.ts +6 -2
- package/src/app/api/openclaw/agent-files/route.ts +57 -0
- package/src/app/api/openclaw/approvals/route.ts +46 -0
- package/src/app/api/openclaw/config-sync/route.ts +33 -0
- package/src/app/api/openclaw/cron/route.ts +52 -0
- package/src/app/api/openclaw/directory/route.ts +4 -3
- package/src/app/api/openclaw/discover/route.ts +3 -2
- package/src/app/api/openclaw/dotenv-keys/route.ts +18 -0
- package/src/app/api/openclaw/exec-config/route.ts +41 -0
- package/src/app/api/openclaw/gateway/route.ts +72 -0
- package/src/app/api/openclaw/history/route.ts +109 -0
- package/src/app/api/openclaw/media/route.ts +53 -0
- package/src/app/api/openclaw/models/route.ts +12 -0
- package/src/app/api/openclaw/permissions/route.ts +39 -0
- package/src/app/api/openclaw/sandbox-env/route.ts +69 -0
- package/src/app/api/openclaw/skills/install/route.ts +32 -0
- package/src/app/api/openclaw/skills/remove/route.ts +24 -0
- package/src/app/api/openclaw/skills/route.ts +82 -0
- package/src/app/api/openclaw/sync/route.ts +3 -2
- package/src/app/api/projects/[id]/route.ts +1 -1
- package/src/app/api/projects/route.ts +1 -1
- package/src/app/api/secrets/[id]/route.ts +1 -1
- package/src/app/api/sessions/[id]/edit-resend/route.ts +22 -0
- package/src/app/api/sessions/[id]/fork/route.ts +44 -0
- package/src/app/api/sessions/[id]/messages/route.ts +18 -1
- package/src/app/api/sessions/[id]/route.ts +12 -3
- package/src/app/api/sessions/route.ts +6 -2
- package/src/app/globals.css +14 -0
- package/src/app/layout.tsx +5 -20
- package/src/cli/index.js +33 -1
- package/src/cli/spec.js +40 -0
- package/src/components/agents/agent-avatar.tsx +45 -0
- package/src/components/agents/agent-card.tsx +19 -5
- package/src/components/agents/agent-chat-list.tsx +31 -24
- package/src/components/agents/agent-files-editor.tsx +185 -0
- package/src/components/agents/agent-list.tsx +82 -3
- package/src/components/agents/agent-sheet.tsx +31 -0
- package/src/components/agents/cron-job-form.tsx +137 -0
- package/src/components/agents/exec-config-panel.tsx +147 -0
- package/src/components/agents/inspector-panel.tsx +310 -0
- package/src/components/agents/openclaw-skills-panel.tsx +230 -0
- package/src/components/agents/permission-preset-selector.tsx +79 -0
- package/src/components/agents/personality-builder.tsx +111 -0
- package/src/components/agents/sandbox-env-panel.tsx +72 -0
- package/src/components/agents/skill-install-dialog.tsx +102 -0
- package/src/components/agents/trash-list.tsx +109 -0
- package/src/components/chat/chat-area.tsx +14 -2
- package/src/components/chat/chat-header.tsx +168 -4
- package/src/components/chat/chat-preview-panel.tsx +113 -0
- package/src/components/chat/exec-approval-card.tsx +89 -0
- package/src/components/chat/message-bubble.tsx +218 -36
- package/src/components/chat/message-list.tsx +135 -31
- package/src/components/chat/streaming-bubble.tsx +59 -10
- package/src/components/chat/suggestions-bar.tsx +74 -0
- package/src/components/chat/thinking-indicator.tsx +20 -6
- package/src/components/chat/tool-call-bubble.tsx +89 -16
- package/src/components/chat/tool-request-banner.tsx +20 -2
- package/src/components/chat/trace-block.tsx +103 -0
- package/src/components/projects/project-list.tsx +1 -0
- package/src/components/settings/gateway-connection-panel.tsx +278 -0
- package/src/components/shared/avatar.tsx +13 -2
- package/src/components/shared/settings/settings-page.tsx +1 -0
- package/src/components/tasks/task-board.tsx +1 -1
- package/src/components/tasks/task-sheet.tsx +12 -12
- package/src/hooks/use-continuous-speech.ts +42 -5
- package/src/hooks/use-openclaw-gateway.ts +63 -0
- package/src/lib/notification-sounds.ts +58 -0
- package/src/lib/personality-parser.ts +97 -0
- package/src/lib/providers/openclaw.ts +17 -2
- package/src/lib/runtime-loop.ts +2 -2
- package/src/lib/server/chat-execution.ts +44 -2
- package/src/lib/server/connectors/bluebubbles.test.ts +17 -8
- package/src/lib/server/connectors/bluebubbles.ts +5 -2
- package/src/lib/server/connectors/googlechat.ts +14 -10
- package/src/lib/server/connectors/manager.ts +37 -15
- package/src/lib/server/connectors/openclaw.ts +1 -0
- package/src/lib/server/daemon-state.ts +11 -0
- package/src/lib/server/main-agent-loop.ts +2 -3
- package/src/lib/server/main-session.ts +21 -0
- package/src/lib/server/openclaw-config-sync.ts +107 -0
- package/src/lib/server/openclaw-exec-config.ts +52 -0
- package/src/lib/server/openclaw-gateway.ts +291 -0
- package/src/lib/server/openclaw-history-merge.ts +36 -0
- package/src/lib/server/openclaw-models.ts +56 -0
- package/src/lib/server/openclaw-permission-presets.ts +64 -0
- package/src/lib/server/openclaw-sync.ts +11 -10
- package/src/lib/server/queue.ts +2 -1
- package/src/lib/server/session-tools/connector.ts +4 -4
- package/src/lib/server/session-tools/delegate.ts +19 -3
- package/src/lib/server/session-tools/file.ts +20 -20
- package/src/lib/server/session-tools/index.ts +2 -2
- package/src/lib/server/session-tools/openclaw-nodes.ts +6 -6
- package/src/lib/server/session-tools/sandbox.ts +2 -2
- package/src/lib/server/session-tools/search-providers.ts +13 -6
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +2 -2
- package/src/lib/server/session-tools/shell.ts +1 -1
- package/src/lib/server/session-tools/web.ts +8 -8
- package/src/lib/server/storage.ts +62 -11
- package/src/lib/server/stream-agent-chat.ts +24 -4
- package/src/lib/server/suggestions.ts +20 -0
- package/src/lib/server/ws-hub.ts +14 -0
- package/src/stores/use-app-store.ts +53 -1
- package/src/stores/use-approval-store.ts +78 -0
- package/src/stores/use-chat-store.ts +153 -5
- package/src/types/index.ts +127 -1
- package/tsconfig.json +13 -4
|
@@ -7,6 +7,7 @@ import rehypeHighlight from 'rehype-highlight'
|
|
|
7
7
|
import type { Message } from '@/types'
|
|
8
8
|
import { useAppStore } from '@/stores/use-app-store'
|
|
9
9
|
import { AiAvatar } from '@/components/shared/avatar'
|
|
10
|
+
import { AgentAvatar } from '@/components/agents/agent-avatar'
|
|
10
11
|
import { CodeBlock } from './code-block'
|
|
11
12
|
import { ToolCallBubble } from './tool-call-bubble'
|
|
12
13
|
import { ToolRequestBanner } from './tool-request-banner'
|
|
@@ -170,6 +171,8 @@ function heartbeatSummary(text: string): string {
|
|
|
170
171
|
|
|
171
172
|
const IMAGE_ATTACH_RE = /\.(png|jpg|jpeg|gif|webp|svg|bmp|ico)$/i
|
|
172
173
|
const PREVIEWABLE_ATTACH_RE = /\.(html?|svg)$/i
|
|
174
|
+
const CODE_ATTACH_RE = /\.(js|jsx|ts|tsx|css|json|md|txt|py|sh|rb|go|rs|c|cpp|h|java|yaml|yml|toml|xml|sql|graphql)$/i
|
|
175
|
+
const PDF_ATTACH_RE = /\.pdf$/i
|
|
173
176
|
const FILE_TYPE_COLORS: Record<string, string> = {
|
|
174
177
|
html: 'text-orange-400', htm: 'text-orange-400', svg: 'text-emerald-400',
|
|
175
178
|
js: 'text-yellow-400', jsx: 'text-yellow-400', ts: 'text-blue-400', tsx: 'text-blue-400',
|
|
@@ -186,10 +189,49 @@ function parseAttachmentUrl(filePath?: string, fileUrl?: string) {
|
|
|
186
189
|
|
|
187
190
|
function AttachmentChip({ url, filename, isUserMsg }: { url: string; filename: string; isUserMsg?: boolean }) {
|
|
188
191
|
const isImage = IMAGE_ATTACH_RE.test(filename)
|
|
192
|
+
const isCode = CODE_ATTACH_RE.test(filename)
|
|
193
|
+
const isPdf = PDF_ATTACH_RE.test(filename)
|
|
194
|
+
const [lightbox, setLightbox] = useState(false)
|
|
195
|
+
const [codePreview, setCodePreview] = useState<string | null>(null)
|
|
196
|
+
const [codeExpanded, setCodeExpanded] = useState(false)
|
|
197
|
+
|
|
189
198
|
if (isImage) {
|
|
190
199
|
return (
|
|
191
|
-
|
|
192
|
-
|
|
200
|
+
<>
|
|
201
|
+
<img
|
|
202
|
+
src={url} alt="Attached"
|
|
203
|
+
loading="lazy"
|
|
204
|
+
className="max-w-[240px] rounded-[12px] mb-2 border border-white/10 cursor-pointer hover:border-white/25 transition-colors"
|
|
205
|
+
onClick={() => setLightbox(true)}
|
|
206
|
+
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
|
207
|
+
/>
|
|
208
|
+
{lightbox && (
|
|
209
|
+
<div
|
|
210
|
+
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm cursor-pointer"
|
|
211
|
+
onClick={() => setLightbox(false)}
|
|
212
|
+
>
|
|
213
|
+
<img src={url} alt="Preview" className="max-w-[90vw] max-h-[90vh] rounded-[12px] shadow-2xl" />
|
|
214
|
+
</div>
|
|
215
|
+
)}
|
|
216
|
+
</>
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (isPdf) {
|
|
221
|
+
return (
|
|
222
|
+
<div className="mb-2 rounded-[12px] border border-white/[0.08] bg-[rgba(255,255,255,0.02)] overflow-hidden" style={{ maxWidth: 480 }}>
|
|
223
|
+
<div className="flex items-center gap-3 px-4 py-2.5">
|
|
224
|
+
<div className="flex items-center justify-center w-8 h-8 rounded-[8px] shrink-0 bg-red-500/10 text-red-400">
|
|
225
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
|
226
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
227
|
+
<polyline points="14 2 14 8 20 8" />
|
|
228
|
+
</svg>
|
|
229
|
+
</div>
|
|
230
|
+
<span className="text-[13px] font-500 truncate flex-1">{filename}</span>
|
|
231
|
+
<a href={url} download={filename} className="text-[11px] font-600 text-text-3 hover:text-text-2 no-underline">Download</a>
|
|
232
|
+
</div>
|
|
233
|
+
<iframe src={url} loading="lazy" className="w-full h-[300px] border-t border-white/[0.06]" title={filename} />
|
|
234
|
+
</div>
|
|
193
235
|
)
|
|
194
236
|
}
|
|
195
237
|
|
|
@@ -197,7 +239,6 @@ function AttachmentChip({ url, filename, isUserMsg }: { url: string; filename: s
|
|
|
197
239
|
const colorClass = FILE_TYPE_COLORS[ext] || 'text-text-3'
|
|
198
240
|
const isPreviewable = PREVIEWABLE_ATTACH_RE.test(filename)
|
|
199
241
|
|
|
200
|
-
// Solid bg so chip is readable on both user (purple) and assistant bubbles
|
|
201
242
|
const chipBg = isUserMsg
|
|
202
243
|
? 'bg-[rgba(0,0,0,0.25)] border-white/[0.12]'
|
|
203
244
|
: 'bg-[rgba(255,255,255,0.04)] border-white/[0.08]'
|
|
@@ -206,40 +247,85 @@ function AttachmentChip({ url, filename, isUserMsg }: { url: string; filename: s
|
|
|
206
247
|
? 'bg-white/[0.12] hover:bg-white/[0.18] text-white/80'
|
|
207
248
|
: 'bg-white/[0.06] hover:bg-white/[0.10] text-text-3'
|
|
208
249
|
|
|
250
|
+
const handleCodePreview = async () => {
|
|
251
|
+
if (codePreview !== null) { setCodeExpanded(!codeExpanded); return }
|
|
252
|
+
try {
|
|
253
|
+
const serveUrl = `/api/files/serve?path=${encodeURIComponent(url.replace('/api/uploads/', ''))}`
|
|
254
|
+
const res = await fetch(url.startsWith('/api/files/') ? url : serveUrl)
|
|
255
|
+
if (!res.ok) return
|
|
256
|
+
const text = await res.text()
|
|
257
|
+
setCodePreview(text)
|
|
258
|
+
setCodeExpanded(true)
|
|
259
|
+
} catch {
|
|
260
|
+
// ignore
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
209
264
|
return (
|
|
210
|
-
<div className=
|
|
211
|
-
<div className={`flex items-center
|
|
212
|
-
<
|
|
213
|
-
<
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
<
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
265
|
+
<div className="mb-2">
|
|
266
|
+
<div className={`flex items-center gap-3 px-4 py-2.5 rounded-[12px] border ${chipBg}`}>
|
|
267
|
+
<div className={`flex items-center justify-center w-8 h-8 rounded-[8px] shrink-0 ${iconBg} ${colorClass}`}>
|
|
268
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
|
269
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
270
|
+
<polyline points="14 2 14 8 20 8" />
|
|
271
|
+
</svg>
|
|
272
|
+
</div>
|
|
273
|
+
<div className="flex flex-col flex-1 min-w-0">
|
|
274
|
+
<span className={`text-[13px] font-500 truncate ${isUserMsg ? 'text-white' : 'text-text'}`}>{filename}</span>
|
|
275
|
+
<span className={`text-[11px] uppercase tracking-wide ${isUserMsg ? 'text-white/50' : 'text-text-3/70'}`}>{ext || 'file'}</span>
|
|
276
|
+
</div>
|
|
277
|
+
{isCode && (
|
|
278
|
+
<button
|
|
279
|
+
onClick={handleCodePreview}
|
|
280
|
+
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] text-[11px] font-600 no-underline transition-colors shrink-0 border-none cursor-pointer ${
|
|
281
|
+
isUserMsg ? 'bg-white/[0.15] hover:bg-white/[0.22] text-white' : 'bg-accent-soft hover:bg-accent-soft/80 text-accent-bright'
|
|
282
|
+
}`}
|
|
283
|
+
>
|
|
284
|
+
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
285
|
+
<polyline points="16 18 22 12 16 6" />
|
|
286
|
+
<polyline points="8 6 2 12 8 18" />
|
|
287
|
+
</svg>
|
|
288
|
+
{codeExpanded ? 'Hide' : 'Preview'}
|
|
289
|
+
</button>
|
|
290
|
+
)}
|
|
291
|
+
{isPreviewable && (
|
|
292
|
+
<a href={url} target="_blank" rel="noopener noreferrer"
|
|
293
|
+
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] text-[11px] font-600 no-underline transition-colors shrink-0 ${
|
|
294
|
+
isUserMsg ? 'bg-white/[0.15] hover:bg-white/[0.22] text-white' : 'bg-accent-soft hover:bg-accent-soft/80 text-accent-bright'
|
|
295
|
+
}`}
|
|
296
|
+
title="Preview in new tab">
|
|
297
|
+
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
298
|
+
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
299
|
+
<circle cx="12" cy="12" r="3" />
|
|
300
|
+
</svg>
|
|
301
|
+
Preview
|
|
302
|
+
</a>
|
|
303
|
+
)}
|
|
304
|
+
<a href={url} download={filename}
|
|
305
|
+
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] text-[11px] font-600 no-underline transition-colors shrink-0 ${btnBg}`}>
|
|
227
306
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
228
|
-
<path d="
|
|
229
|
-
<
|
|
307
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
308
|
+
<polyline points="7 10 12 15 17 10" />
|
|
309
|
+
<line x1="12" y1="15" x2="12" y2="3" />
|
|
230
310
|
</svg>
|
|
231
|
-
|
|
311
|
+
Download
|
|
232
312
|
</a>
|
|
313
|
+
</div>
|
|
314
|
+
{isCode && codeExpanded && codePreview !== null && (
|
|
315
|
+
<div className="mt-1 rounded-[10px] border border-white/[0.06] overflow-hidden" style={{ animation: 'fade-in 0.2s ease' }}>
|
|
316
|
+
<CodeBlock className={`language-${ext}`}>
|
|
317
|
+
{codePreview.split('\n').slice(0, codeExpanded ? undefined : 10).join('\n')}
|
|
318
|
+
</CodeBlock>
|
|
319
|
+
{codePreview.split('\n').length > 10 && (
|
|
320
|
+
<button
|
|
321
|
+
onClick={() => setCodeExpanded((v) => !v)}
|
|
322
|
+
className="w-full px-3 py-1.5 text-[10px] text-text-3 hover:text-text-2 bg-white/[0.02] hover:bg-white/[0.04] border-none border-t border-white/[0.06] cursor-pointer transition-colors"
|
|
323
|
+
>
|
|
324
|
+
{codePreview.split('\n').length > 10 ? `Show all ${codePreview.split('\n').length} lines` : 'Show less'}
|
|
325
|
+
</button>
|
|
326
|
+
)}
|
|
327
|
+
</div>
|
|
233
328
|
)}
|
|
234
|
-
<a href={url} download={filename}
|
|
235
|
-
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] text-[11px] font-600 no-underline transition-colors shrink-0 ${btnBg}`}>
|
|
236
|
-
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
237
|
-
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
238
|
-
<polyline points="7 10 12 15 17 10" />
|
|
239
|
-
<line x1="12" y1="15" x2="12" y2="3" />
|
|
240
|
-
</svg>
|
|
241
|
-
Download
|
|
242
|
-
</a>
|
|
243
329
|
</div>
|
|
244
330
|
)
|
|
245
331
|
}
|
|
@@ -280,17 +366,25 @@ function renderAttachments(message: Message) {
|
|
|
280
366
|
interface Props {
|
|
281
367
|
message: Message
|
|
282
368
|
assistantName?: string
|
|
369
|
+
agentAvatarSeed?: string
|
|
370
|
+
agentName?: string
|
|
283
371
|
isLast?: boolean
|
|
284
372
|
onRetry?: () => void
|
|
373
|
+
messageIndex?: number
|
|
374
|
+
onToggleBookmark?: (index: number) => void
|
|
375
|
+
onEditResend?: (index: number, newText: string) => void
|
|
376
|
+
onFork?: (index: number) => void
|
|
285
377
|
}
|
|
286
378
|
|
|
287
|
-
export const MessageBubble = memo(function MessageBubble({ message, assistantName, isLast, onRetry }: Props) {
|
|
379
|
+
export const MessageBubble = memo(function MessageBubble({ message, assistantName, agentAvatarSeed, agentName, isLast, onRetry, messageIndex, onToggleBookmark, onEditResend, onFork }: Props) {
|
|
288
380
|
const isUser = message.role === 'user'
|
|
289
381
|
const isHeartbeat = !isUser && (message.kind === 'heartbeat' || /^\s*HEARTBEAT_OK\b/i.test(message.text || ''))
|
|
290
382
|
const currentUser = useAppStore((s) => s.currentUser)
|
|
291
383
|
const [copied, setCopied] = useState(false)
|
|
292
384
|
const [heartbeatExpanded, setHeartbeatExpanded] = useState(false)
|
|
293
385
|
const [toolEventsExpanded, setToolEventsExpanded] = useState(false)
|
|
386
|
+
const [editing, setEditing] = useState(false)
|
|
387
|
+
const [editText, setEditText] = useState('')
|
|
294
388
|
const toolEvents = message.toolEvents || []
|
|
295
389
|
const hasToolEvents = !isUser && toolEvents.length > 0
|
|
296
390
|
const visibleToolEvents = toolEventsExpanded ? [...toolEvents].reverse() : toolEvents.slice(-1)
|
|
@@ -309,7 +403,7 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
|
|
|
309
403
|
>
|
|
310
404
|
{/* Sender label + timestamp */}
|
|
311
405
|
<div className={`flex items-center gap-2.5 mb-2 px-1 ${isUser ? 'flex-row-reverse' : ''}`}>
|
|
312
|
-
{!isUser && <AiAvatar size="sm" />}
|
|
406
|
+
{!isUser && (agentName ? <AgentAvatar seed={agentAvatarSeed || null} name={agentName} size={36} /> : <AiAvatar size="sm" />)}
|
|
313
407
|
<span className={`text-[12px] font-600 ${isUser ? 'text-accent-bright/70' : 'text-text-3'}`}>
|
|
314
408
|
{isUser ? (currentUser ? currentUser.charAt(0).toUpperCase() + currentUser.slice(1) : 'You') : (assistantName || 'Claude')}
|
|
315
409
|
</span>
|
|
@@ -414,12 +508,12 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
|
|
|
414
508
|
const isVideo = /\.(mp4|webm|mov|avi)$/i.test(src)
|
|
415
509
|
if (isVideo) {
|
|
416
510
|
return (
|
|
417
|
-
<video src={src} controls className="max-w-full rounded-[10px] border border-white/10 my-2" />
|
|
511
|
+
<video src={src} controls preload="none" className="max-w-full rounded-[10px] border border-white/10 my-2" />
|
|
418
512
|
)
|
|
419
513
|
}
|
|
420
514
|
return (
|
|
421
515
|
<a href={src} download target="_blank" rel="noopener noreferrer" className="block my-2">
|
|
422
|
-
<img src={src} alt={alt || 'File'} className="max-w-full rounded-[10px] border border-white/10 hover:border-white/25 transition-colors cursor-pointer" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} />
|
|
516
|
+
<img src={src} alt={alt || 'File'} loading="lazy" className="max-w-full rounded-[10px] border border-white/10 hover:border-white/25 transition-colors cursor-pointer" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} />
|
|
423
517
|
</a>
|
|
424
518
|
)
|
|
425
519
|
},
|
|
@@ -518,6 +612,16 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
|
|
|
518
612
|
toolOutputs={toolEvents.map((e) => e.output || '').filter(Boolean)}
|
|
519
613
|
/>}
|
|
520
614
|
|
|
615
|
+
{/* Bookmark indicator */}
|
|
616
|
+
{message.bookmarked && (
|
|
617
|
+
<div className={`flex items-center gap-1 mt-1 px-1 ${isUser ? 'justify-end' : ''}`}>
|
|
618
|
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="#F59E0B" stroke="#F59E0B" strokeWidth="2" className="shrink-0">
|
|
619
|
+
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
|
620
|
+
</svg>
|
|
621
|
+
<span className="text-[10px] text-[#F59E0B]/70 font-600">Bookmarked</span>
|
|
622
|
+
</div>
|
|
623
|
+
)}
|
|
624
|
+
|
|
521
625
|
{/* Action buttons */}
|
|
522
626
|
<div className={`flex items-center gap-1 mt-1.5 px-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200 ${isUser ? 'justify-end' : ''}`}>
|
|
523
627
|
<button
|
|
@@ -533,6 +637,53 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
|
|
|
533
637
|
</svg>
|
|
534
638
|
{copied ? 'Copied' : 'Copy'}
|
|
535
639
|
</button>
|
|
640
|
+
{typeof messageIndex === 'number' && onToggleBookmark && (
|
|
641
|
+
<button
|
|
642
|
+
onClick={() => onToggleBookmark(messageIndex)}
|
|
643
|
+
aria-label={message.bookmarked ? 'Remove bookmark' : 'Bookmark message'}
|
|
644
|
+
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-[8px] border-none bg-transparent
|
|
645
|
+
text-[11px] font-500 cursor-pointer hover:bg-white/[0.04] transition-all"
|
|
646
|
+
style={{ fontFamily: 'inherit', color: message.bookmarked ? '#F59E0B' : undefined }}
|
|
647
|
+
>
|
|
648
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill={message.bookmarked ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
649
|
+
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
|
650
|
+
</svg>
|
|
651
|
+
{message.bookmarked ? 'Unbookmark' : 'Bookmark'}
|
|
652
|
+
</button>
|
|
653
|
+
)}
|
|
654
|
+
{isUser && typeof messageIndex === 'number' && onEditResend && (
|
|
655
|
+
<button
|
|
656
|
+
onClick={() => { setEditText(message.text); setEditing(true) }}
|
|
657
|
+
aria-label="Edit and resend"
|
|
658
|
+
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-[8px] border-none bg-transparent
|
|
659
|
+
text-[11px] font-500 text-text-3 cursor-pointer hover:text-text-2 hover:bg-white/[0.04] transition-all"
|
|
660
|
+
style={{ fontFamily: 'inherit' }}
|
|
661
|
+
>
|
|
662
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
663
|
+
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
664
|
+
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
665
|
+
</svg>
|
|
666
|
+
Edit
|
|
667
|
+
</button>
|
|
668
|
+
)}
|
|
669
|
+
{typeof messageIndex === 'number' && onFork && (
|
|
670
|
+
<button
|
|
671
|
+
onClick={() => onFork(messageIndex)}
|
|
672
|
+
aria-label="Fork conversation from here"
|
|
673
|
+
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-[8px] border-none bg-transparent
|
|
674
|
+
text-[11px] font-500 text-text-3 cursor-pointer hover:text-text-2 hover:bg-white/[0.04] transition-all"
|
|
675
|
+
style={{ fontFamily: 'inherit' }}
|
|
676
|
+
>
|
|
677
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
678
|
+
<circle cx="12" cy="18" r="3" />
|
|
679
|
+
<circle cx="6" cy="6" r="3" />
|
|
680
|
+
<circle cx="18" cy="6" r="3" />
|
|
681
|
+
<path d="M18 9v1a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V9" />
|
|
682
|
+
<path d="M12 12v3" />
|
|
683
|
+
</svg>
|
|
684
|
+
Fork
|
|
685
|
+
</button>
|
|
686
|
+
)}
|
|
536
687
|
{!isUser && isLast && onRetry && (
|
|
537
688
|
<button
|
|
538
689
|
onClick={onRetry}
|
|
@@ -549,6 +700,37 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
|
|
|
549
700
|
</button>
|
|
550
701
|
)}
|
|
551
702
|
</div>
|
|
703
|
+
|
|
704
|
+
{/* Inline edit mode */}
|
|
705
|
+
{editing && (
|
|
706
|
+
<div className={`max-w-[85%] md:max-w-[72%] mt-2 ${isUser ? 'self-end' : ''}`} style={{ animation: 'fade-in 0.2s ease' }}>
|
|
707
|
+
<textarea
|
|
708
|
+
value={editText}
|
|
709
|
+
onChange={(e) => setEditText(e.target.value)}
|
|
710
|
+
className="w-full min-h-[80px] p-3 rounded-[12px] bg-surface border border-white/[0.08] text-text text-[14px] resize-y outline-none focus:border-accent-bright/30"
|
|
711
|
+
style={{ fontFamily: 'inherit' }}
|
|
712
|
+
/>
|
|
713
|
+
<div className="flex gap-2 mt-2 justify-end">
|
|
714
|
+
<button
|
|
715
|
+
onClick={() => setEditing(false)}
|
|
716
|
+
className="px-3 py-1.5 rounded-[8px] text-[11px] font-600 text-text-3 bg-white/[0.04] hover:bg-white/[0.07] border-none cursor-pointer transition-colors"
|
|
717
|
+
>
|
|
718
|
+
Cancel
|
|
719
|
+
</button>
|
|
720
|
+
<button
|
|
721
|
+
onClick={() => {
|
|
722
|
+
if (editText.trim() && typeof messageIndex === 'number' && onEditResend) {
|
|
723
|
+
onEditResend(messageIndex, editText.trim())
|
|
724
|
+
setEditing(false)
|
|
725
|
+
}
|
|
726
|
+
}}
|
|
727
|
+
className="px-3 py-1.5 rounded-[8px] text-[11px] font-600 text-white bg-accent-bright hover:bg-accent-bright/80 border-none cursor-pointer transition-colors"
|
|
728
|
+
>
|
|
729
|
+
Save & Resend
|
|
730
|
+
</button>
|
|
731
|
+
</div>
|
|
732
|
+
</div>
|
|
733
|
+
)}
|
|
552
734
|
</div>
|
|
553
735
|
)
|
|
554
736
|
})
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
3
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
4
4
|
import type { Message } from '@/types'
|
|
5
5
|
import { useChatStore } from '@/stores/use-chat-store'
|
|
6
6
|
import { useAppStore } from '@/stores/use-app-store'
|
|
7
|
+
import { api } from '@/lib/api-client'
|
|
7
8
|
import { MessageBubble } from './message-bubble'
|
|
8
9
|
import { StreamingBubble } from './streaming-bubble'
|
|
9
10
|
import { ThinkingIndicator } from './thinking-indicator'
|
|
11
|
+
import { SuggestionsBar } from './suggestions-bar'
|
|
12
|
+
import { ExecApprovalCard } from './exec-approval-card'
|
|
13
|
+
import { useApprovalStore } from '@/stores/use-approval-store'
|
|
10
14
|
|
|
11
15
|
function dateSeparator(ts: number): string {
|
|
12
16
|
const d = new Date(ts)
|
|
@@ -26,14 +30,19 @@ interface Props {
|
|
|
26
30
|
export function MessageList({ messages, streaming }: Props) {
|
|
27
31
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
28
32
|
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
|
29
|
-
const
|
|
33
|
+
const snapUntilRef = useRef(0)
|
|
30
34
|
const prevSessionIdRef = useRef<string | null>(null)
|
|
31
|
-
const
|
|
35
|
+
const displayText = useChatStore((s) => s.displayText)
|
|
36
|
+
const setMessages = useChatStore((s) => s.setMessages)
|
|
32
37
|
const retryLastMessage = useChatStore((s) => s.retryLastMessage)
|
|
38
|
+
const editAndResend = useChatStore((s) => s.editAndResend)
|
|
39
|
+
const sendMessage = useChatStore((s) => s.sendMessage)
|
|
40
|
+
const forkSession = useAppStore((s) => s.forkSession)
|
|
33
41
|
const session = useAppStore((s) => {
|
|
34
42
|
const id = s.currentSessionId
|
|
35
43
|
return id ? s.sessions[id] : null
|
|
36
44
|
})
|
|
45
|
+
const sessionId = session?.id ?? null
|
|
37
46
|
const agents = useAppStore((s) => s.agents)
|
|
38
47
|
const agent = session?.agentId ? agents[session.agentId] : null
|
|
39
48
|
const appSettings = useAppStore((s) => s.appSettings)
|
|
@@ -49,6 +58,37 @@ export function MessageList({ messages, streaming }: Props) {
|
|
|
49
58
|
const [unreadCount, setUnreadCount] = useState(0)
|
|
50
59
|
const prevMsgCountRef = useRef(messages.length)
|
|
51
60
|
|
|
61
|
+
// Bookmark filter
|
|
62
|
+
const [bookmarkFilter, setBookmarkFilter] = useState(false)
|
|
63
|
+
|
|
64
|
+
const toggleBookmark = useCallback(async (index: number) => {
|
|
65
|
+
if (!sessionId) return
|
|
66
|
+
const msg = messages[index]
|
|
67
|
+
if (!msg) return
|
|
68
|
+
const next = !msg.bookmarked
|
|
69
|
+
try {
|
|
70
|
+
await api('PUT', `/sessions/${sessionId}/messages`, { messageIndex: index, bookmarked: next })
|
|
71
|
+
const updated = [...messages]
|
|
72
|
+
updated[index] = { ...updated[index], bookmarked: next }
|
|
73
|
+
setMessages(updated)
|
|
74
|
+
} catch (err: unknown) {
|
|
75
|
+
console.error('Failed to toggle bookmark:', err instanceof Error ? err.message : String(err))
|
|
76
|
+
}
|
|
77
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
78
|
+
}, [sessionId, messages])
|
|
79
|
+
|
|
80
|
+
const handleEditResend = useCallback(async (index: number, newText: string) => {
|
|
81
|
+
if (!sessionId || !editAndResend) return
|
|
82
|
+
await editAndResend(index, newText)
|
|
83
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
84
|
+
}, [sessionId])
|
|
85
|
+
|
|
86
|
+
const handleFork = useCallback(async (index: number) => {
|
|
87
|
+
if (!sessionId || !forkSession) return
|
|
88
|
+
await forkSession(sessionId, index)
|
|
89
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
90
|
+
}, [sessionId])
|
|
91
|
+
|
|
52
92
|
// In-thread search
|
|
53
93
|
const [searchOpen, setSearchOpen] = useState(false)
|
|
54
94
|
const [searchQuery, setSearchQuery] = useState('')
|
|
@@ -56,9 +96,9 @@ export function MessageList({ messages, streaming }: Props) {
|
|
|
56
96
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
57
97
|
|
|
58
98
|
const isHeartbeatMessage = (msg: Message) =>
|
|
59
|
-
msg.role === 'assistant' && (msg.kind === 'heartbeat' || /^\s*HEARTBEAT_OK\b/i.test(msg.text || ''))
|
|
99
|
+
msg.role === 'assistant' && (msg.kind === 'heartbeat' || /^\s*HEARTBEAT_OK\b/i.test(msg.text || '') || /^\s*NO_MESSAGE\b/i.test(msg.text || ''))
|
|
60
100
|
const isHeartbeatOk = (msg: Message) =>
|
|
61
|
-
msg.suppressed === true || (msg.kind === 'heartbeat' && /^\s*HEARTBEAT_OK\b/i.test(msg.text || ''))
|
|
101
|
+
msg.suppressed === true || (msg.kind === 'heartbeat' && (/^\s*HEARTBEAT_OK\b/i.test(msg.text || '') || /^\s*NO_MESSAGE\b/i.test(msg.text || '')))
|
|
62
102
|
|
|
63
103
|
const displayedMessages: Message[] = []
|
|
64
104
|
for (const msg of messages) {
|
|
@@ -79,9 +119,14 @@ export function MessageList({ messages, streaming }: Props) {
|
|
|
79
119
|
}
|
|
80
120
|
}
|
|
81
121
|
|
|
122
|
+
// Apply bookmark filter
|
|
123
|
+
const filteredMessages = bookmarkFilter
|
|
124
|
+
? displayedMessages.filter((msg) => msg.bookmarked)
|
|
125
|
+
: displayedMessages
|
|
126
|
+
|
|
82
127
|
// Search matches
|
|
83
128
|
const searchMatches = searchQuery.trim()
|
|
84
|
-
?
|
|
129
|
+
? filteredMessages
|
|
85
130
|
.map((msg, i) => ({ msg, i }))
|
|
86
131
|
.filter(({ msg }) => msg.text.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
87
132
|
: []
|
|
@@ -99,6 +144,10 @@ export function MessageList({ messages, streaming }: Props) {
|
|
|
99
144
|
const nearBottom = isNearBottom(el)
|
|
100
145
|
wasAtBottomRef.current = nearBottom
|
|
101
146
|
setShowScrollToBottom(!nearBottom)
|
|
147
|
+
// Cancel snap window if user manually scrolls away
|
|
148
|
+
if (!nearBottom && Date.now() < snapUntilRef.current) {
|
|
149
|
+
snapUntilRef.current = 0
|
|
150
|
+
}
|
|
102
151
|
if (nearBottom && unreadRef.current > 0) {
|
|
103
152
|
unreadRef.current = 0
|
|
104
153
|
setUnreadCount(0)
|
|
@@ -115,31 +164,52 @@ export function MessageList({ messages, streaming }: Props) {
|
|
|
115
164
|
}
|
|
116
165
|
}, [messages.length, isNearBottom])
|
|
117
166
|
|
|
118
|
-
// Detect session switch
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
prevSessionIdRef.current
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
167
|
+
// Detect session switch — set snap window and reset scroll state.
|
|
168
|
+
// Must fire before the scroll positioning layoutEffect below.
|
|
169
|
+
useLayoutEffect(() => {
|
|
170
|
+
if (sessionId !== prevSessionIdRef.current) {
|
|
171
|
+
prevSessionIdRef.current = sessionId
|
|
172
|
+
wasAtBottomRef.current = true
|
|
173
|
+
snapUntilRef.current = Date.now() + 2000
|
|
174
|
+
}
|
|
175
|
+
}, [sessionId])
|
|
125
176
|
|
|
126
|
-
|
|
177
|
+
// Position scroll before paint — no setState here to avoid cascading renders.
|
|
178
|
+
// The onScroll handler and the state-update effect below handle UI state.
|
|
179
|
+
useLayoutEffect(() => {
|
|
127
180
|
const el = scrollRef.current
|
|
128
|
-
if (!el) return
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
181
|
+
if (!el || messages.length === 0) return
|
|
182
|
+
|
|
183
|
+
const snapping = Date.now() < snapUntilRef.current
|
|
184
|
+
|
|
185
|
+
if (snapping || wasAtBottomRef.current) {
|
|
132
186
|
el.scrollTop = el.scrollHeight
|
|
133
|
-
setShowScrollToBottom(false)
|
|
134
187
|
wasAtBottomRef.current = true
|
|
135
|
-
return
|
|
136
|
-
}
|
|
137
|
-
// Auto-scroll if user was at bottom before new content arrived
|
|
138
|
-
if (wasAtBottomRef.current) {
|
|
139
|
-
el.scrollTop = el.scrollHeight
|
|
140
188
|
}
|
|
189
|
+
}, [messages.length, displayText])
|
|
190
|
+
|
|
191
|
+
// Update scroll-related UI state after render (separate from layoutEffect to avoid cascading)
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
const el = scrollRef.current
|
|
194
|
+
if (!el || messages.length === 0) return
|
|
141
195
|
updateScrollState()
|
|
142
|
-
}, [messages.length,
|
|
196
|
+
}, [messages.length, displayText, updateScrollState])
|
|
197
|
+
|
|
198
|
+
// Re-snap when content resizes during snap window (lazy images increasing scrollHeight)
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
const el = scrollRef.current
|
|
201
|
+
if (!el) return
|
|
202
|
+
const content = el.firstElementChild as HTMLElement | null
|
|
203
|
+
if (!content) return
|
|
204
|
+
|
|
205
|
+
const observer = new ResizeObserver(() => {
|
|
206
|
+
if (Date.now() < snapUntilRef.current || wasAtBottomRef.current) {
|
|
207
|
+
el.scrollTop = el.scrollHeight
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
observer.observe(content)
|
|
211
|
+
return () => observer.disconnect()
|
|
212
|
+
}, [sessionId])
|
|
143
213
|
|
|
144
214
|
const handleScrollToBottom = useCallback(() => {
|
|
145
215
|
const el = scrollRef.current
|
|
@@ -179,7 +249,7 @@ export function MessageList({ messages, streaming }: Props) {
|
|
|
179
249
|
}, [searchOpen])
|
|
180
250
|
|
|
181
251
|
return (
|
|
182
|
-
<div className="relative flex-1 min-h-0">
|
|
252
|
+
<div className="relative flex-1 min-h-0 min-w-0">
|
|
183
253
|
{/* In-thread search bar */}
|
|
184
254
|
{searchOpen && (
|
|
185
255
|
<div className="absolute top-0 left-0 right-0 z-20 flex items-center gap-2 px-6 md:px-12 lg:px-16 py-2 bg-surface/95 backdrop-blur-sm border-b border-white/[0.06]">
|
|
@@ -224,6 +294,15 @@ export function MessageList({ messages, streaming }: Props) {
|
|
|
224
294
|
>
|
|
225
295
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="m6 9 6 6 6-6" /></svg>
|
|
226
296
|
</button>
|
|
297
|
+
<button
|
|
298
|
+
onClick={() => setBookmarkFilter((v) => !v)}
|
|
299
|
+
aria-label={bookmarkFilter ? 'Show all messages' : 'Show bookmarked only'}
|
|
300
|
+
className={`p-1 rounded-[6px] hover:bg-white/[0.04] cursor-pointer border-none bg-transparent transition-colors ${bookmarkFilter ? 'text-[#F59E0B]' : 'text-text-3 hover:text-text-2'}`}
|
|
301
|
+
>
|
|
302
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill={bookmarkFilter ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
303
|
+
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
|
304
|
+
</svg>
|
|
305
|
+
</button>
|
|
227
306
|
<button
|
|
228
307
|
onClick={() => { setSearchOpen(false); setSearchQuery(''); setSearchIdx(0) }}
|
|
229
308
|
aria-label="Close search"
|
|
@@ -240,14 +319,16 @@ export function MessageList({ messages, streaming }: Props) {
|
|
|
240
319
|
className="h-full overflow-y-auto px-6 md:px-12 lg:px-16 py-6"
|
|
241
320
|
>
|
|
242
321
|
<div className="flex flex-col gap-6">
|
|
243
|
-
{
|
|
322
|
+
{filteredMessages.map((msg, i) => {
|
|
323
|
+
// Find original index in the full messages array for API calls
|
|
324
|
+
const originalIndex = messages.indexOf(msg)
|
|
244
325
|
const isLastAssistant = msg.role === 'assistant' && !streaming
|
|
245
|
-
&&
|
|
326
|
+
&& filteredMessages.slice(i + 1).every((m) => m.role !== 'assistant')
|
|
246
327
|
const isSearchMatch = searchQuery && searchMatches.some((m) => m.i === i)
|
|
247
328
|
const isCurrentMatch = searchQuery && searchMatches[searchIdx]?.i === i
|
|
248
329
|
|
|
249
330
|
// Date separator
|
|
250
|
-
const prevMsg = i > 0 ?
|
|
331
|
+
const prevMsg = i > 0 ? filteredMessages[i - 1] : null
|
|
251
332
|
const showDateSep = msg.time && (!prevMsg?.time || new Date(msg.time).toDateString() !== new Date(prevMsg.time).toDateString())
|
|
252
333
|
|
|
253
334
|
return (
|
|
@@ -265,15 +346,25 @@ export function MessageList({ messages, streaming }: Props) {
|
|
|
265
346
|
<MessageBubble
|
|
266
347
|
message={msg}
|
|
267
348
|
assistantName={assistantName}
|
|
349
|
+
agentAvatarSeed={agent?.avatarSeed}
|
|
350
|
+
agentName={agent?.name}
|
|
268
351
|
isLast={isLastAssistant}
|
|
269
352
|
onRetry={isLastAssistant ? retryLastMessage : undefined}
|
|
353
|
+
messageIndex={originalIndex >= 0 ? originalIndex : undefined}
|
|
354
|
+
onToggleBookmark={toggleBookmark}
|
|
355
|
+
onEditResend={handleEditResend}
|
|
356
|
+
onFork={handleFork}
|
|
270
357
|
/>
|
|
271
358
|
</div>
|
|
272
359
|
</div>
|
|
273
360
|
)
|
|
274
361
|
})}
|
|
275
|
-
|
|
276
|
-
{streaming &&
|
|
362
|
+
<ApprovalCards agentId={agent?.id} />
|
|
363
|
+
{streaming && !displayText && <ThinkingIndicator assistantName={assistantName} agentAvatarSeed={agent?.avatarSeed} agentName={agent?.name} />}
|
|
364
|
+
{streaming && displayText && <StreamingBubble text={displayText} assistantName={assistantName} agentAvatarSeed={agent?.avatarSeed} agentName={agent?.name} />}
|
|
365
|
+
{!streaming && filteredMessages.length > 0 && filteredMessages[filteredMessages.length - 1]?.role === 'assistant' && (
|
|
366
|
+
<SuggestionsBar lastMessage={filteredMessages[filteredMessages.length - 1]} onSend={sendMessage} />
|
|
367
|
+
)}
|
|
277
368
|
</div>
|
|
278
369
|
</div>
|
|
279
370
|
{showScrollToBottom && (
|
|
@@ -297,3 +388,16 @@ export function MessageList({ messages, streaming }: Props) {
|
|
|
297
388
|
</div>
|
|
298
389
|
)
|
|
299
390
|
}
|
|
391
|
+
|
|
392
|
+
function ApprovalCards({ agentId }: { agentId?: string | null }) {
|
|
393
|
+
const approvals = useApprovalStore((s) => s.approvals)
|
|
394
|
+
const cards = Object.values(approvals).filter((a) => !agentId || a.agentId === agentId)
|
|
395
|
+
if (!cards.length) return null
|
|
396
|
+
return (
|
|
397
|
+
<>
|
|
398
|
+
{cards.map((a) => (
|
|
399
|
+
<ExecApprovalCard key={a.id} approval={a} />
|
|
400
|
+
))}
|
|
401
|
+
</>
|
|
402
|
+
)
|
|
403
|
+
}
|