@swarmclawai/swarmclaw 0.5.2 → 0.6.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 +42 -7
- package/bin/swarmclaw.js +76 -16
- package/next.config.ts +11 -1
- package/package.json +4 -2
- package/public/screenshots/agents.png +0 -0
- package/public/screenshots/dashboard.png +0 -0
- package/public/screenshots/providers.png +0 -0
- package/public/screenshots/tasks.png +0 -0
- package/scripts/postinstall.mjs +18 -0
- package/src/app/api/chatrooms/[id]/chat/route.ts +410 -0
- package/src/app/api/chatrooms/[id]/members/route.ts +82 -0
- package/src/app/api/chatrooms/[id]/pins/route.ts +39 -0
- package/src/app/api/chatrooms/[id]/reactions/route.ts +42 -0
- package/src/app/api/chatrooms/[id]/route.ts +84 -0
- package/src/app/api/chatrooms/route.ts +50 -0
- package/src/app/api/credentials/route.ts +2 -3
- package/src/app/api/knowledge/[id]/route.ts +13 -2
- package/src/app/api/knowledge/route.ts +8 -1
- package/src/app/api/memory/route.ts +8 -0
- package/src/app/api/notifications/[id]/route.ts +27 -0
- package/src/app/api/notifications/route.ts +68 -0
- package/src/app/api/orchestrator/run/route.ts +1 -1
- package/src/app/api/plugins/install/route.ts +2 -2
- package/src/app/api/search/route.ts +155 -0
- package/src/app/api/sessions/[id]/chat/route.ts +2 -0
- package/src/app/api/sessions/[id]/edit-resend/route.ts +1 -1
- package/src/app/api/sessions/[id]/fork/route.ts +1 -1
- package/src/app/api/sessions/route.ts +3 -3
- package/src/app/api/settings/route.ts +9 -0
- package/src/app/api/setup/check-provider/route.ts +3 -16
- package/src/app/api/skills/[id]/route.ts +6 -0
- package/src/app/api/skills/route.ts +6 -0
- package/src/app/api/tasks/[id]/route.ts +20 -0
- package/src/app/api/tasks/bulk/route.ts +100 -0
- package/src/app/api/tasks/route.ts +1 -0
- package/src/app/api/usage/route.ts +45 -0
- package/src/app/api/webhooks/[id]/route.ts +15 -1
- package/src/app/globals.css +58 -15
- package/src/app/page.tsx +142 -13
- package/src/cli/index.js +42 -0
- package/src/cli/index.test.js +30 -0
- package/src/cli/spec.js +32 -0
- package/src/components/agents/agent-avatar.tsx +57 -10
- package/src/components/agents/agent-card.tsx +48 -15
- package/src/components/agents/agent-chat-list.tsx +123 -10
- package/src/components/agents/agent-list.tsx +50 -19
- package/src/components/agents/agent-sheet.tsx +56 -63
- package/src/components/auth/access-key-gate.tsx +10 -3
- package/src/components/auth/setup-wizard.tsx +2 -2
- package/src/components/auth/user-picker.tsx +31 -3
- package/src/components/chat/activity-moment.tsx +169 -0
- package/src/components/chat/chat-header.tsx +2 -0
- package/src/components/chat/chat-tool-toggles.tsx +1 -1
- package/src/components/chat/file-path-chip.tsx +125 -0
- package/src/components/chat/markdown-utils.ts +9 -0
- package/src/components/chat/message-bubble.tsx +46 -295
- package/src/components/chat/message-list.tsx +50 -1
- package/src/components/chat/streaming-bubble.tsx +36 -46
- package/src/components/chat/suggestions-bar.tsx +1 -1
- package/src/components/chat/thinking-indicator.tsx +72 -10
- package/src/components/chat/tool-call-bubble.tsx +66 -70
- package/src/components/chat/tool-request-banner.tsx +31 -7
- package/src/components/chat/transfer-agent-picker.tsx +63 -0
- package/src/components/chatrooms/agent-hover-card.tsx +124 -0
- package/src/components/chatrooms/chatroom-input.tsx +320 -0
- package/src/components/chatrooms/chatroom-list.tsx +123 -0
- package/src/components/chatrooms/chatroom-message.tsx +427 -0
- package/src/components/chatrooms/chatroom-sheet.tsx +215 -0
- package/src/components/chatrooms/chatroom-tool-request-banner.tsx +134 -0
- package/src/components/chatrooms/chatroom-typing-bar.tsx +88 -0
- package/src/components/chatrooms/chatroom-view.tsx +344 -0
- package/src/components/chatrooms/reaction-picker.tsx +273 -0
- package/src/components/connectors/connector-sheet.tsx +34 -47
- package/src/components/home/home-view.tsx +501 -0
- package/src/components/input/chat-input.tsx +79 -41
- package/src/components/knowledge/knowledge-list.tsx +31 -1
- package/src/components/knowledge/knowledge-sheet.tsx +83 -2
- package/src/components/layout/app-layout.tsx +209 -83
- package/src/components/layout/mobile-header.tsx +2 -0
- package/src/components/layout/update-banner.tsx +2 -2
- package/src/components/logs/log-list.tsx +2 -2
- package/src/components/mcp-servers/mcp-server-sheet.tsx +1 -1
- package/src/components/memory/memory-agent-list.tsx +143 -0
- package/src/components/memory/memory-browser.tsx +205 -0
- package/src/components/memory/memory-card.tsx +34 -7
- package/src/components/memory/memory-detail.tsx +359 -120
- package/src/components/memory/memory-sheet.tsx +157 -23
- package/src/components/plugins/plugin-list.tsx +1 -1
- package/src/components/plugins/plugin-sheet.tsx +1 -1
- package/src/components/projects/project-detail.tsx +509 -0
- package/src/components/projects/project-list.tsx +195 -59
- package/src/components/providers/provider-list.tsx +2 -2
- package/src/components/providers/provider-sheet.tsx +3 -3
- package/src/components/schedules/schedule-card.tsx +3 -2
- package/src/components/schedules/schedule-list.tsx +1 -1
- package/src/components/schedules/schedule-sheet.tsx +25 -25
- package/src/components/secrets/secret-sheet.tsx +47 -24
- package/src/components/secrets/secrets-list.tsx +18 -8
- package/src/components/sessions/new-session-sheet.tsx +33 -65
- package/src/components/sessions/session-card.tsx +45 -14
- package/src/components/sessions/session-list.tsx +35 -18
- package/src/components/shared/agent-picker-list.tsx +90 -0
- package/src/components/shared/agent-switch-dialog.tsx +156 -0
- package/src/components/shared/attachment-chip.tsx +165 -0
- package/src/components/shared/avatar.tsx +10 -1
- package/src/components/shared/check-icon.tsx +12 -0
- package/src/components/shared/confirm-dialog.tsx +1 -1
- package/src/components/shared/empty-state.tsx +32 -0
- package/src/components/shared/file-preview.tsx +34 -0
- package/src/components/shared/form-styles.ts +2 -0
- package/src/components/shared/keyboard-shortcuts-dialog.tsx +116 -0
- package/src/components/shared/notification-center.tsx +223 -0
- package/src/components/shared/profile-sheet.tsx +115 -0
- package/src/components/shared/reply-quote.tsx +26 -0
- package/src/components/shared/search-dialog.tsx +296 -0
- package/src/components/shared/section-label.tsx +12 -0
- package/src/components/shared/settings/plugin-manager.tsx +1 -1
- package/src/components/shared/settings/section-providers.tsx +1 -1
- package/src/components/shared/settings/section-secrets.tsx +1 -1
- package/src/components/shared/settings/section-theme.tsx +95 -0
- package/src/components/shared/settings/section-user-preferences.tsx +39 -0
- package/src/components/shared/settings/settings-page.tsx +180 -27
- package/src/components/shared/settings/settings-sheet.tsx +9 -73
- package/src/components/shared/sheet-footer.tsx +33 -0
- package/src/components/skills/skill-list.tsx +61 -30
- package/src/components/skills/skill-sheet.tsx +81 -2
- package/src/components/tasks/task-board.tsx +448 -26
- package/src/components/tasks/task-card.tsx +46 -9
- package/src/components/tasks/task-column.tsx +62 -3
- package/src/components/tasks/task-list.tsx +12 -4
- package/src/components/tasks/task-sheet.tsx +89 -72
- package/src/components/ui/hover-card.tsx +52 -0
- package/src/components/usage/metrics-dashboard.tsx +78 -0
- package/src/components/usage/usage-list.tsx +1 -1
- package/src/components/webhooks/webhook-sheet.tsx +1 -1
- package/src/hooks/use-view-router.ts +69 -19
- package/src/instrumentation.ts +15 -1
- package/src/lib/chat.ts +2 -0
- package/src/lib/cron-human.ts +114 -0
- package/src/lib/memory.ts +3 -0
- package/src/lib/server/chat-execution.ts +24 -4
- package/src/lib/server/connectors/manager.ts +11 -0
- package/src/lib/server/context-manager.ts +225 -13
- package/src/lib/server/create-notification.ts +42 -0
- package/src/lib/server/daemon-state.ts +165 -10
- package/src/lib/server/execution-log.ts +1 -0
- package/src/lib/server/heartbeat-service.ts +40 -5
- package/src/lib/server/heartbeat-wake.ts +110 -0
- package/src/lib/server/langgraph-checkpoint.ts +1 -0
- package/src/lib/server/memory-consolidation.ts +92 -0
- package/src/lib/server/memory-db.ts +51 -6
- package/src/lib/server/openclaw-gateway.ts +9 -1
- package/src/lib/server/provider-health.ts +125 -0
- package/src/lib/server/queue.ts +5 -4
- package/src/lib/server/scheduler.ts +8 -0
- package/src/lib/server/session-run-manager.ts +4 -0
- package/src/lib/server/session-tools/chatroom.ts +136 -0
- package/src/lib/server/session-tools/context-mgmt.ts +36 -18
- package/src/lib/server/session-tools/index.ts +2 -0
- package/src/lib/server/session-tools/memory.ts +6 -1
- package/src/lib/server/storage.ts +80 -29
- package/src/lib/server/stream-agent-chat.ts +153 -47
- package/src/lib/server/system-events.ts +49 -0
- package/src/lib/server/ws-hub.ts +11 -0
- package/src/lib/soul-suggestions.ts +109 -0
- package/src/lib/tasks.ts +4 -1
- package/src/lib/view-routes.ts +36 -1
- package/src/lib/ws-client.ts +14 -4
- package/src/proxy.ts +79 -2
- package/src/stores/use-app-store.ts +94 -3
- package/src/stores/use-chat-store.ts +48 -3
- package/src/stores/use-chatroom-store.ts +276 -0
- package/src/types/index.ts +69 -2
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react'
|
|
4
|
+
import { api } from '@/lib/api-client'
|
|
5
|
+
|
|
6
|
+
export const FILE_PATH_RE = /^(\/[\w./-]+\.\w{1,10})$/
|
|
7
|
+
export const DIR_PATH_RE = /^(\/[\w./-]+)\/?$/
|
|
8
|
+
const PREVIEWABLE_EXT = /\.(html?|svg|css|js|jsx|ts|tsx|json|md|txt|py|sh)$/i
|
|
9
|
+
const SERVEABLE_EXT = /\.(html?|svg|css|js|jsx|ts|tsx)$/i
|
|
10
|
+
|
|
11
|
+
export function FilePathChip({ filePath }: { filePath: string }) {
|
|
12
|
+
const canPreview = PREVIEWABLE_EXT.test(filePath)
|
|
13
|
+
const canServe = SERVEABLE_EXT.test(filePath)
|
|
14
|
+
const serveUrl = `/api/files/serve?path=${encodeURIComponent(filePath)}`
|
|
15
|
+
|
|
16
|
+
const [serverState, setServerState] = useState<{
|
|
17
|
+
running: boolean; url?: string; loading: boolean; type?: string; framework?: string
|
|
18
|
+
}>({ running: false, loading: false })
|
|
19
|
+
|
|
20
|
+
// Check if a server is already running for this path on mount
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!canServe) return
|
|
23
|
+
api<{ running: boolean; url?: string; type?: string }>('POST', '/preview-server', { action: 'status', path: filePath })
|
|
24
|
+
.then((res) => { if (res.running) setServerState({ running: true, url: res.url, type: res.type, loading: false }) })
|
|
25
|
+
.catch((err: unknown) => console.error('Dev server check failed:', err))
|
|
26
|
+
}, [filePath, canServe])
|
|
27
|
+
|
|
28
|
+
const handleStartServer = async () => {
|
|
29
|
+
setServerState((s) => ({ ...s, loading: true }))
|
|
30
|
+
try {
|
|
31
|
+
const res = await api<{ running: boolean; url?: string; type?: string; framework?: string }>('POST', '/preview-server', { action: 'start', path: filePath })
|
|
32
|
+
setServerState({ running: res.running, url: res.url, type: res.type, framework: res.framework, loading: false })
|
|
33
|
+
} catch {
|
|
34
|
+
setServerState((s) => ({ ...s, loading: false }))
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const handleStopServer = async () => {
|
|
39
|
+
setServerState((s) => ({ ...s, loading: true }))
|
|
40
|
+
try {
|
|
41
|
+
await api('POST', '/preview-server', { action: 'stop', path: filePath })
|
|
42
|
+
setServerState({ running: false, loading: false })
|
|
43
|
+
} catch {
|
|
44
|
+
setServerState((s) => ({ ...s, loading: false }))
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const frameworkLabel = serverState.framework
|
|
49
|
+
? serverState.framework.charAt(0).toUpperCase() + serverState.framework.slice(1)
|
|
50
|
+
: null
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-[8px] bg-white/[0.06] border border-white/[0.08] font-mono text-[13px]">
|
|
54
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="text-text-3/50 shrink-0">
|
|
55
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
56
|
+
<polyline points="14 2 14 8 20 8" />
|
|
57
|
+
</svg>
|
|
58
|
+
<span className="text-sky-400">{filePath}</span>
|
|
59
|
+
{canPreview && !serverState.running && (
|
|
60
|
+
<a
|
|
61
|
+
href={serveUrl}
|
|
62
|
+
target="_blank"
|
|
63
|
+
rel="noopener noreferrer"
|
|
64
|
+
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-[4px] bg-white/[0.06] hover:bg-white/[0.10] text-[10px] font-600 text-text-3 hover:text-text-2 no-underline transition-colors cursor-pointer"
|
|
65
|
+
title="Open file"
|
|
66
|
+
>
|
|
67
|
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
68
|
+
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
|
69
|
+
<polyline points="15 3 21 3 21 9" />
|
|
70
|
+
<line x1="10" y1="14" x2="21" y2="3" />
|
|
71
|
+
</svg>
|
|
72
|
+
Open
|
|
73
|
+
</a>
|
|
74
|
+
)}
|
|
75
|
+
{canServe && !serverState.running && (
|
|
76
|
+
<button
|
|
77
|
+
onClick={handleStartServer}
|
|
78
|
+
disabled={serverState.loading}
|
|
79
|
+
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-[4px] bg-emerald-500/15 hover:bg-emerald-500/25 text-emerald-400 text-[10px] font-600 border-none cursor-pointer transition-colors disabled:opacity-50"
|
|
80
|
+
title="Start preview server — auto-detects npm projects (React, Next, Vite, etc.) and runs the dev command"
|
|
81
|
+
>
|
|
82
|
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
|
|
83
|
+
<polygon points="5 3 19 12 5 21 5 3" />
|
|
84
|
+
</svg>
|
|
85
|
+
{serverState.loading ? 'Starting...' : 'Serve'}
|
|
86
|
+
</button>
|
|
87
|
+
)}
|
|
88
|
+
{canServe && serverState.running && (
|
|
89
|
+
<>
|
|
90
|
+
{frameworkLabel && (
|
|
91
|
+
<span className="px-1.5 py-0.5 rounded-[4px] bg-indigo-500/15 text-indigo-300 text-[9px] font-700 uppercase tracking-wider">
|
|
92
|
+
{frameworkLabel}
|
|
93
|
+
</span>
|
|
94
|
+
)}
|
|
95
|
+
{serverState.type === 'npm' && (
|
|
96
|
+
<span className="px-1.5 py-0.5 rounded-[4px] bg-amber-500/15 text-amber-300 text-[9px] font-700 uppercase tracking-wider">
|
|
97
|
+
npm
|
|
98
|
+
</span>
|
|
99
|
+
)}
|
|
100
|
+
<a
|
|
101
|
+
href={serverState.url}
|
|
102
|
+
target="_blank"
|
|
103
|
+
rel="noopener noreferrer"
|
|
104
|
+
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-[4px] bg-emerald-500/15 hover:bg-emerald-500/25 text-emerald-400 text-[10px] font-600 no-underline transition-colors"
|
|
105
|
+
title="Open preview server"
|
|
106
|
+
>
|
|
107
|
+
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" style={{ animation: 'pulse 2s ease infinite' }} />
|
|
108
|
+
{serverState.url}
|
|
109
|
+
</a>
|
|
110
|
+
<button
|
|
111
|
+
onClick={handleStopServer}
|
|
112
|
+
disabled={serverState.loading}
|
|
113
|
+
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-[4px] bg-red-500/15 hover:bg-red-500/25 text-red-400 text-[10px] font-600 border-none cursor-pointer transition-colors disabled:opacity-50"
|
|
114
|
+
title="Stop preview server"
|
|
115
|
+
>
|
|
116
|
+
<svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor">
|
|
117
|
+
<rect x="4" y="4" width="16" height="16" rx="2" />
|
|
118
|
+
</svg>
|
|
119
|
+
Stop
|
|
120
|
+
</button>
|
|
121
|
+
</>
|
|
122
|
+
)}
|
|
123
|
+
</span>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Detect if text contains structured markdown (code blocks, headings, lists, tables) */
|
|
2
|
+
export function isStructuredMarkdown(text: string): boolean {
|
|
3
|
+
if (!text) return false
|
|
4
|
+
return /```/.test(text)
|
|
5
|
+
|| /^#{1,4}\s/m.test(text)
|
|
6
|
+
|| /^[-*]\s/m.test(text)
|
|
7
|
+
|| /^\d+\.\s/m.test(text)
|
|
8
|
+
|| /\|.*\|.*\|/m.test(text)
|
|
9
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { memo, useState, useCallback
|
|
3
|
+
import { memo, useState, useCallback } from 'react'
|
|
4
4
|
import ReactMarkdown from 'react-markdown'
|
|
5
5
|
import remarkGfm from 'remark-gfm'
|
|
6
6
|
import rehypeHighlight from 'rehype-highlight'
|
|
@@ -11,128 +11,10 @@ import { AgentAvatar } from '@/components/agents/agent-avatar'
|
|
|
11
11
|
import { CodeBlock } from './code-block'
|
|
12
12
|
import { ToolCallBubble } from './tool-call-bubble'
|
|
13
13
|
import { ToolRequestBanner } from './tool-request-banner'
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const PREVIEWABLE_EXT = /\.(html?|svg|css|js|jsx|ts|tsx|json|md|txt|py|sh)$/i
|
|
19
|
-
const SERVEABLE_EXT = /\.(html?|svg|css|js|jsx|ts|tsx)$/i
|
|
20
|
-
|
|
21
|
-
function FilePathChip({ filePath }: { filePath: string }) {
|
|
22
|
-
const canPreview = PREVIEWABLE_EXT.test(filePath)
|
|
23
|
-
const canServe = SERVEABLE_EXT.test(filePath)
|
|
24
|
-
const serveUrl = `/api/files/serve?path=${encodeURIComponent(filePath)}`
|
|
25
|
-
|
|
26
|
-
const [serverState, setServerState] = useState<{
|
|
27
|
-
running: boolean; url?: string; loading: boolean; type?: string; framework?: string
|
|
28
|
-
}>({ running: false, loading: false })
|
|
29
|
-
|
|
30
|
-
// Check if a server is already running for this path on mount
|
|
31
|
-
useEffect(() => {
|
|
32
|
-
if (!canServe) return
|
|
33
|
-
api<{ running: boolean; url?: string; type?: string }>('POST', '/preview-server', { action: 'status', path: filePath })
|
|
34
|
-
.then((res) => { if (res.running) setServerState({ running: true, url: res.url, type: res.type, loading: false }) })
|
|
35
|
-
.catch((err) => console.error('Dev server check failed:', err))
|
|
36
|
-
}, [filePath, canServe])
|
|
37
|
-
|
|
38
|
-
const handleStartServer = async () => {
|
|
39
|
-
setServerState((s) => ({ ...s, loading: true }))
|
|
40
|
-
try {
|
|
41
|
-
const res = await api<{ running: boolean; url?: string; type?: string; framework?: string }>('POST', '/preview-server', { action: 'start', path: filePath })
|
|
42
|
-
setServerState({ running: res.running, url: res.url, type: res.type, framework: res.framework, loading: false })
|
|
43
|
-
} catch {
|
|
44
|
-
setServerState((s) => ({ ...s, loading: false }))
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const handleStopServer = async () => {
|
|
49
|
-
setServerState((s) => ({ ...s, loading: true }))
|
|
50
|
-
try {
|
|
51
|
-
await api('POST', '/preview-server', { action: 'stop', path: filePath })
|
|
52
|
-
setServerState({ running: false, loading: false })
|
|
53
|
-
} catch {
|
|
54
|
-
setServerState((s) => ({ ...s, loading: false }))
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const frameworkLabel = serverState.framework
|
|
59
|
-
? serverState.framework.charAt(0).toUpperCase() + serverState.framework.slice(1)
|
|
60
|
-
: null
|
|
61
|
-
|
|
62
|
-
return (
|
|
63
|
-
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-[8px] bg-white/[0.06] border border-white/[0.08] font-mono text-[13px]">
|
|
64
|
-
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="text-text-3/50 shrink-0">
|
|
65
|
-
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
66
|
-
<polyline points="14 2 14 8 20 8" />
|
|
67
|
-
</svg>
|
|
68
|
-
<span className="text-sky-400">{filePath}</span>
|
|
69
|
-
{canPreview && !serverState.running && (
|
|
70
|
-
<a
|
|
71
|
-
href={serveUrl}
|
|
72
|
-
target="_blank"
|
|
73
|
-
rel="noopener noreferrer"
|
|
74
|
-
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-[4px] bg-white/[0.06] hover:bg-white/[0.10] text-[10px] font-600 text-text-3 hover:text-text-2 no-underline transition-colors cursor-pointer"
|
|
75
|
-
title="Open file"
|
|
76
|
-
>
|
|
77
|
-
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
78
|
-
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
|
79
|
-
<polyline points="15 3 21 3 21 9" />
|
|
80
|
-
<line x1="10" y1="14" x2="21" y2="3" />
|
|
81
|
-
</svg>
|
|
82
|
-
Open
|
|
83
|
-
</a>
|
|
84
|
-
)}
|
|
85
|
-
{canServe && !serverState.running && (
|
|
86
|
-
<button
|
|
87
|
-
onClick={handleStartServer}
|
|
88
|
-
disabled={serverState.loading}
|
|
89
|
-
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-[4px] bg-emerald-500/15 hover:bg-emerald-500/25 text-emerald-400 text-[10px] font-600 border-none cursor-pointer transition-colors disabled:opacity-50"
|
|
90
|
-
title="Start preview server — auto-detects npm projects (React, Next, Vite, etc.) and runs the dev command"
|
|
91
|
-
>
|
|
92
|
-
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
|
|
93
|
-
<polygon points="5 3 19 12 5 21 5 3" />
|
|
94
|
-
</svg>
|
|
95
|
-
{serverState.loading ? 'Starting...' : 'Serve'}
|
|
96
|
-
</button>
|
|
97
|
-
)}
|
|
98
|
-
{canServe && serverState.running && (
|
|
99
|
-
<>
|
|
100
|
-
{frameworkLabel && (
|
|
101
|
-
<span className="px-1.5 py-0.5 rounded-[4px] bg-indigo-500/15 text-indigo-300 text-[9px] font-700 uppercase tracking-wider">
|
|
102
|
-
{frameworkLabel}
|
|
103
|
-
</span>
|
|
104
|
-
)}
|
|
105
|
-
{serverState.type === 'npm' && (
|
|
106
|
-
<span className="px-1.5 py-0.5 rounded-[4px] bg-amber-500/15 text-amber-300 text-[9px] font-700 uppercase tracking-wider">
|
|
107
|
-
npm
|
|
108
|
-
</span>
|
|
109
|
-
)}
|
|
110
|
-
<a
|
|
111
|
-
href={serverState.url}
|
|
112
|
-
target="_blank"
|
|
113
|
-
rel="noopener noreferrer"
|
|
114
|
-
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-[4px] bg-emerald-500/15 hover:bg-emerald-500/25 text-emerald-400 text-[10px] font-600 no-underline transition-colors"
|
|
115
|
-
title="Open preview server"
|
|
116
|
-
>
|
|
117
|
-
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" style={{ animation: 'pulse 2s ease infinite' }} />
|
|
118
|
-
{serverState.url}
|
|
119
|
-
</a>
|
|
120
|
-
<button
|
|
121
|
-
onClick={handleStopServer}
|
|
122
|
-
disabled={serverState.loading}
|
|
123
|
-
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-[4px] bg-red-500/15 hover:bg-red-500/25 text-red-400 text-[10px] font-600 border-none cursor-pointer transition-colors disabled:opacity-50"
|
|
124
|
-
title="Stop preview server"
|
|
125
|
-
>
|
|
126
|
-
<svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor">
|
|
127
|
-
<rect x="4" y="4" width="16" height="16" rx="2" />
|
|
128
|
-
</svg>
|
|
129
|
-
Stop
|
|
130
|
-
</button>
|
|
131
|
-
</>
|
|
132
|
-
)}
|
|
133
|
-
</span>
|
|
134
|
-
)
|
|
135
|
-
}
|
|
14
|
+
import { AttachmentChip, parseAttachmentUrl } from '@/components/shared/attachment-chip'
|
|
15
|
+
import { isStructuredMarkdown } from './markdown-utils'
|
|
16
|
+
import { FilePathChip, FILE_PATH_RE, DIR_PATH_RE } from './file-path-chip'
|
|
17
|
+
import { TransferAgentPicker } from './transfer-agent-picker'
|
|
136
18
|
|
|
137
19
|
function fmtTime(ts: number): string {
|
|
138
20
|
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
@@ -169,166 +51,8 @@ function heartbeatSummary(text: string): string {
|
|
|
169
51
|
return clean.length > 180 ? `${clean.slice(0, 180)}...` : clean
|
|
170
52
|
}
|
|
171
53
|
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
176
|
-
const FILE_TYPE_COLORS: Record<string, string> = {
|
|
177
|
-
html: 'text-orange-400', htm: 'text-orange-400', svg: 'text-emerald-400',
|
|
178
|
-
js: 'text-yellow-400', jsx: 'text-yellow-400', ts: 'text-blue-400', tsx: 'text-blue-400',
|
|
179
|
-
py: 'text-green-400', json: 'text-amber-300', css: 'text-purple-400', scss: 'text-pink-400',
|
|
180
|
-
md: 'text-text-2', txt: 'text-text-3', pdf: 'text-red-400',
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function parseAttachmentUrl(filePath?: string, fileUrl?: string) {
|
|
184
|
-
const url = fileUrl || (filePath ? `/api/uploads/${filePath.split('/').pop()}` : '')
|
|
185
|
-
const rawName = filePath?.split('/').pop() || fileUrl?.split('/').pop() || 'file'
|
|
186
|
-
const filename = rawName.replace(/^[a-f0-9]+-/, '').split('?')[0]
|
|
187
|
-
return { url, filename }
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function AttachmentChip({ url, filename, isUserMsg }: { url: string; filename: string; isUserMsg?: boolean }) {
|
|
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
|
-
|
|
198
|
-
if (isImage) {
|
|
199
|
-
return (
|
|
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>
|
|
235
|
-
)
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
|
239
|
-
const colorClass = FILE_TYPE_COLORS[ext] || 'text-text-3'
|
|
240
|
-
const isPreviewable = PREVIEWABLE_ATTACH_RE.test(filename)
|
|
241
|
-
|
|
242
|
-
const chipBg = isUserMsg
|
|
243
|
-
? 'bg-[rgba(0,0,0,0.25)] border-white/[0.12]'
|
|
244
|
-
: 'bg-[rgba(255,255,255,0.04)] border-white/[0.08]'
|
|
245
|
-
const iconBg = isUserMsg ? 'bg-white/[0.12]' : 'bg-white/[0.05]'
|
|
246
|
-
const btnBg = isUserMsg
|
|
247
|
-
? 'bg-white/[0.12] hover:bg-white/[0.18] text-white/80'
|
|
248
|
-
: 'bg-white/[0.06] hover:bg-white/[0.10] text-text-3'
|
|
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
|
-
|
|
264
|
-
return (
|
|
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}`}>
|
|
306
|
-
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
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" />
|
|
310
|
-
</svg>
|
|
311
|
-
Download
|
|
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>
|
|
328
|
-
)}
|
|
329
|
-
</div>
|
|
330
|
-
)
|
|
331
|
-
}
|
|
54
|
+
// AttachmentChip, parseAttachmentUrl, regex constants, and FILE_TYPE_COLORS
|
|
55
|
+
// are now imported from @/components/shared/attachment-chip
|
|
332
56
|
|
|
333
57
|
function renderAttachments(message: Message) {
|
|
334
58
|
const isUser = message.role === 'user'
|
|
@@ -374,18 +98,11 @@ interface Props {
|
|
|
374
98
|
onToggleBookmark?: (index: number) => void
|
|
375
99
|
onEditResend?: (index: number, newText: string) => void
|
|
376
100
|
onFork?: (index: number) => void
|
|
101
|
+
onTransferToAgent?: (messageIndex: number, agentId: string) => void
|
|
102
|
+
momentOverlay?: React.ReactNode
|
|
377
103
|
}
|
|
378
104
|
|
|
379
|
-
function
|
|
380
|
-
if (!text) return false
|
|
381
|
-
return /```/.test(text)
|
|
382
|
-
|| /^#{1,4}\s/m.test(text)
|
|
383
|
-
|| /^[-*]\s/m.test(text)
|
|
384
|
-
|| /^\d+\.\s/m.test(text)
|
|
385
|
-
|| /\|.*\|.*\|/m.test(text)
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
export const MessageBubble = memo(function MessageBubble({ message, assistantName, agentAvatarSeed, agentName, isLast, onRetry, messageIndex, onToggleBookmark, onEditResend, onFork }: Props) {
|
|
105
|
+
export const MessageBubble = memo(function MessageBubble({ message, assistantName, agentAvatarSeed, agentName, isLast, onRetry, messageIndex, onToggleBookmark, onEditResend, onFork, onTransferToAgent, momentOverlay }: Props) {
|
|
389
106
|
const isUser = message.role === 'user'
|
|
390
107
|
const isHeartbeat = !isUser && (message.kind === 'heartbeat' || /^\s*HEARTBEAT_OK\b/i.test(message.text || ''))
|
|
391
108
|
const currentUser = useAppStore((s) => s.currentUser)
|
|
@@ -394,6 +111,7 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
|
|
|
394
111
|
const [toolEventsExpanded, setToolEventsExpanded] = useState(false)
|
|
395
112
|
const [editing, setEditing] = useState(false)
|
|
396
113
|
const [editText, setEditText] = useState('')
|
|
114
|
+
const [transferPickerOpen, setTransferPickerOpen] = useState(false)
|
|
397
115
|
const toolEvents = message.toolEvents || []
|
|
398
116
|
const hasToolEvents = !isUser && toolEvents.length > 0
|
|
399
117
|
const visibleToolEvents = toolEventsExpanded ? [...toolEvents].reverse() : toolEvents.slice(-1)
|
|
@@ -408,12 +126,20 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
|
|
|
408
126
|
|
|
409
127
|
return (
|
|
410
128
|
<div
|
|
411
|
-
className={`group ${isUser ? 'flex flex-col items-end' : 'flex flex-col items-start'}`}
|
|
129
|
+
className={`group ${isUser ? 'flex flex-col items-end' : 'flex flex-col items-start relative pl-[44px]'}`}
|
|
412
130
|
style={{ animation: `${isUser ? 'msg-in-right' : 'msg-in-left'} 0.35s cubic-bezier(0.16, 1, 0.3, 1)` }}
|
|
413
131
|
>
|
|
132
|
+
{/* Avatar on spine (assistant) */}
|
|
133
|
+
{!isUser && (
|
|
134
|
+
<div className="absolute left-[4px] top-0">
|
|
135
|
+
<div style={momentOverlay ? { animation: 'avatar-moment-pulse 0.6s ease' } : undefined}>
|
|
136
|
+
{agentName ? <AgentAvatar seed={agentAvatarSeed || null} name={agentName} size={28} /> : <AiAvatar size="sm" />}
|
|
137
|
+
</div>
|
|
138
|
+
{momentOverlay}
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
414
141
|
{/* Sender label + timestamp */}
|
|
415
142
|
<div className={`flex items-center gap-2.5 mb-2 px-1 ${isUser ? 'flex-row-reverse' : ''}`}>
|
|
416
|
-
{!isUser && (agentName ? <AgentAvatar seed={agentAvatarSeed || null} name={agentName} size={36} /> : <AiAvatar size="sm" />)}
|
|
417
143
|
<span className={`text-[12px] font-600 ${isUser ? 'text-accent-bright/70' : 'text-text-3'}`}>
|
|
418
144
|
{isUser ? (currentUser ? currentUser.charAt(0).toUpperCase() + currentUser.slice(1) : 'You') : (assistantName || 'Claude')}
|
|
419
145
|
</span>
|
|
@@ -709,6 +435,31 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
|
|
|
709
435
|
Retry
|
|
710
436
|
</button>
|
|
711
437
|
)}
|
|
438
|
+
{!isUser && typeof messageIndex === 'number' && onTransferToAgent && (
|
|
439
|
+
<div className="relative">
|
|
440
|
+
<button
|
|
441
|
+
onClick={() => setTransferPickerOpen(!transferPickerOpen)}
|
|
442
|
+
aria-label="Transfer to another agent"
|
|
443
|
+
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-[8px] border-none bg-transparent
|
|
444
|
+
text-[11px] font-500 text-text-3 cursor-pointer hover:text-text-2 hover:bg-white/[0.04] transition-all"
|
|
445
|
+
style={{ fontFamily: 'inherit' }}
|
|
446
|
+
>
|
|
447
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
448
|
+
<path d="M8 3L4 7l4 4" />
|
|
449
|
+
<path d="M4 7h16" />
|
|
450
|
+
<path d="M16 21l4-4-4-4" />
|
|
451
|
+
<path d="M20 17H4" />
|
|
452
|
+
</svg>
|
|
453
|
+
Transfer
|
|
454
|
+
</button>
|
|
455
|
+
{transferPickerOpen && (
|
|
456
|
+
<TransferAgentPicker
|
|
457
|
+
onSelect={(agentId) => { onTransferToAgent(messageIndex, agentId); setTransferPickerOpen(false) }}
|
|
458
|
+
onClose={() => setTransferPickerOpen(false)}
|
|
459
|
+
/>
|
|
460
|
+
)}
|
|
461
|
+
</div>
|
|
462
|
+
)}
|
|
712
463
|
</div>
|
|
713
464
|
|
|
714
465
|
{/* Inline edit mode */}
|
|
@@ -11,7 +11,9 @@ import { StreamingBubble } from './streaming-bubble'
|
|
|
11
11
|
import { ThinkingIndicator } from './thinking-indicator'
|
|
12
12
|
import { SuggestionsBar } from './suggestions-bar'
|
|
13
13
|
import { ExecApprovalCard } from './exec-approval-card'
|
|
14
|
+
import { HeartbeatMoment, ActivityMoment, isNotableTool } from './activity-moment'
|
|
14
15
|
import { useApprovalStore } from '@/stores/use-approval-store'
|
|
16
|
+
import { useWs } from '@/hooks/use-ws'
|
|
15
17
|
|
|
16
18
|
const INTRO_GREETINGS = [
|
|
17
19
|
'What can I help you with?',
|
|
@@ -71,6 +73,33 @@ export function MessageList({ messages, streaming }: Props) {
|
|
|
71
73
|
const showOk = appSettings.heartbeatShowOk ?? false
|
|
72
74
|
const showAlerts = appSettings.heartbeatShowAlerts ?? true
|
|
73
75
|
|
|
76
|
+
// Moment overlay for last assistant message (heartbeat or tool events)
|
|
77
|
+
type MomentType = { kind: 'heartbeat' } | { kind: 'tool'; name: string; input: string }
|
|
78
|
+
const [currentMoment, setCurrentMoment] = useState<MomentType | null>(null)
|
|
79
|
+
|
|
80
|
+
const heartbeatTopic = agent?.id ? `heartbeat:agent:${agent.id}` : ''
|
|
81
|
+
useWs(heartbeatTopic, () => {
|
|
82
|
+
setCurrentMoment({ kind: 'heartbeat' })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// Detect notable tool events on latest assistant message when messages change
|
|
86
|
+
const prevToolKeyRef = useRef<string | null>(null)
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
const last = messages[messages.length - 1]
|
|
89
|
+
if (!last || last.role !== 'assistant' || !last.toolEvents?.length) return
|
|
90
|
+
const events = last.toolEvents
|
|
91
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
92
|
+
if (isNotableTool(events[i].name)) {
|
|
93
|
+
const key = `${last.time}-${events[i].name}-${i}`
|
|
94
|
+
if (key !== prevToolKeyRef.current) {
|
|
95
|
+
prevToolKeyRef.current = key
|
|
96
|
+
setCurrentMoment({ kind: 'tool', name: events[i].name, input: events[i].input || '' })
|
|
97
|
+
}
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}, [messages])
|
|
102
|
+
|
|
74
103
|
// Unread count tracking
|
|
75
104
|
const unreadRef = useRef(0)
|
|
76
105
|
const [unreadCount, setUnreadCount] = useState(0)
|
|
@@ -336,7 +365,9 @@ export function MessageList({ messages, streaming }: Props) {
|
|
|
336
365
|
onScroll={updateScrollState}
|
|
337
366
|
className="h-full overflow-y-auto px-6 md:px-12 lg:px-16 py-6 fade-up"
|
|
338
367
|
>
|
|
339
|
-
<div className="flex flex-col gap-6">
|
|
368
|
+
<div className="flex flex-col gap-6 relative">
|
|
369
|
+
{/* Chat spine — vertical line for assistant messages */}
|
|
370
|
+
<div className="absolute left-[15px] top-0 bottom-0 w-px bg-white/[0.06] pointer-events-none" />
|
|
340
371
|
{filteredMessages.length === 0 && !streaming && (
|
|
341
372
|
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center" style={{ animation: 'fadeUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) both' }}>
|
|
342
373
|
<AgentAvatar seed={agent?.avatarSeed || null} name={agent?.name || 'Agent'} size={48} />
|
|
@@ -358,6 +389,23 @@ export function MessageList({ messages, streaming }: Props) {
|
|
|
358
389
|
const prevMsg = i > 0 ? filteredMessages[i - 1] : null
|
|
359
390
|
const showDateSep = msg.time && (!prevMsg?.time || new Date(msg.time).toDateString() !== new Date(prevMsg.time).toDateString())
|
|
360
391
|
|
|
392
|
+
// Moment overlay — only on the last assistant message
|
|
393
|
+
let momentOverlay: React.ReactNode = null
|
|
394
|
+
if (isLastAssistant && currentMoment && !streaming) {
|
|
395
|
+
if (currentMoment.kind === 'heartbeat') {
|
|
396
|
+
momentOverlay = <HeartbeatMoment onDismiss={() => setCurrentMoment(null)} />
|
|
397
|
+
} else {
|
|
398
|
+
momentOverlay = (
|
|
399
|
+
<ActivityMoment
|
|
400
|
+
key={`${currentMoment.name}-${Date.now()}`}
|
|
401
|
+
toolName={currentMoment.name}
|
|
402
|
+
toolInput={currentMoment.input}
|
|
403
|
+
onDismiss={() => setCurrentMoment(null)}
|
|
404
|
+
/>
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
361
409
|
return (
|
|
362
410
|
<div key={`${msg.time}-${i}`}>
|
|
363
411
|
{showDateSep && (
|
|
@@ -381,6 +429,7 @@ export function MessageList({ messages, streaming }: Props) {
|
|
|
381
429
|
onToggleBookmark={toggleBookmark}
|
|
382
430
|
onEditResend={handleEditResend}
|
|
383
431
|
onFork={handleFork}
|
|
432
|
+
momentOverlay={momentOverlay}
|
|
384
433
|
/>
|
|
385
434
|
</div>
|
|
386
435
|
</div>
|