@swarmclawai/swarmclaw 0.4.0 → 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 +21 -4
- package/bin/server-cmd.js +28 -19
- package/next.config.ts +13 -0
- package/package.json +3 -1
- package/src/app/api/agents/[id]/route.ts +39 -22
- package/src/app/api/agents/[id]/thread/route.ts +2 -2
- package/src/app/api/agents/route.ts +3 -2
- package/src/app/api/agents/trash/route.ts +44 -0
- package/src/app/api/clawhub/install/route.ts +2 -2
- package/src/app/api/connectors/[id]/route.ts +17 -7
- package/src/app/api/connectors/[id]/webhook/route.ts +103 -0
- package/src/app/api/connectors/route.ts +6 -3
- package/src/app/api/credentials/[id]/route.ts +2 -1
- package/src/app/api/credentials/route.ts +2 -2
- package/src/app/api/documents/route.ts +2 -2
- package/src/app/api/files/serve/route.ts +8 -0
- package/src/app/api/knowledge/[id]/route.ts +5 -4
- package/src/app/api/knowledge/upload/route.ts +2 -2
- package/src/app/api/mcp-servers/[id]/route.ts +11 -14
- package/src/app/api/mcp-servers/[id]/test/route.ts +2 -1
- package/src/app/api/mcp-servers/[id]/tools/route.ts +2 -1
- package/src/app/api/mcp-servers/route.ts +2 -2
- package/src/app/api/memory/[id]/route.ts +9 -8
- package/src/app/api/memory/route.ts +2 -2
- package/src/app/api/memory-images/[filename]/route.ts +2 -1
- 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 +27 -0
- package/src/app/api/openclaw/discover/route.ts +62 -0
- 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 +31 -0
- package/src/app/api/orchestrator/run/route.ts +2 -2
- package/src/app/api/projects/[id]/route.ts +55 -0
- package/src/app/api/projects/route.ts +27 -0
- package/src/app/api/providers/[id]/models/route.ts +2 -1
- package/src/app/api/providers/[id]/route.ts +13 -15
- package/src/app/api/providers/route.ts +2 -2
- package/src/app/api/schedules/[id]/route.ts +16 -18
- package/src/app/api/schedules/[id]/run/route.ts +4 -3
- package/src/app/api/schedules/route.ts +2 -2
- package/src/app/api/secrets/[id]/route.ts +16 -17
- package/src/app/api/secrets/route.ts +2 -2
- package/src/app/api/sessions/[id]/clear/route.ts +2 -1
- package/src/app/api/sessions/[id]/deploy/route.ts +2 -1
- package/src/app/api/sessions/[id]/devserver/route.ts +2 -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 +20 -2
- package/src/app/api/sessions/[id]/retry/route.ts +2 -1
- package/src/app/api/sessions/[id]/route.ts +14 -4
- package/src/app/api/sessions/route.ts +8 -4
- package/src/app/api/skills/[id]/route.ts +23 -21
- package/src/app/api/skills/import/route.ts +2 -2
- package/src/app/api/skills/route.ts +2 -2
- package/src/app/api/tasks/[id]/approve/route.ts +2 -1
- package/src/app/api/tasks/[id]/route.ts +6 -5
- package/src/app/api/tasks/route.ts +2 -2
- package/src/app/api/tts/stream/route.ts +48 -0
- package/src/app/api/upload/route.ts +2 -2
- package/src/app/api/uploads/[filename]/route.ts +4 -1
- package/src/app/api/webhooks/[id]/route.ts +29 -31
- package/src/app/api/webhooks/route.ts +2 -2
- package/src/app/globals.css +14 -0
- package/src/app/layout.tsx +5 -20
- package/src/app/page.tsx +3 -24
- package/src/cli/index.js +60 -0
- package/src/cli/index.ts +1 -1
- package/src/cli/spec.js +42 -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 +84 -3
- package/src/components/agents/agent-sheet.tsx +147 -14
- 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 +41 -6
- package/src/components/chat/chat-header.tsx +305 -29
- 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 +98 -19
- package/src/components/chat/tool-request-banner.tsx +20 -2
- package/src/components/chat/trace-block.tsx +103 -0
- package/src/components/chat/voice-overlay.tsx +80 -0
- package/src/components/connectors/connector-list.tsx +6 -2
- package/src/components/connectors/connector-sheet.tsx +31 -7
- package/src/components/layout/app-layout.tsx +47 -25
- package/src/components/projects/project-list.tsx +123 -0
- package/src/components/projects/project-sheet.tsx +135 -0
- package/src/components/schedules/schedule-list.tsx +3 -1
- package/src/components/sessions/new-session-sheet.tsx +6 -6
- package/src/components/sessions/session-card.tsx +1 -1
- package/src/components/sessions/session-list.tsx +7 -7
- package/src/components/settings/gateway-connection-panel.tsx +278 -0
- package/src/components/shared/avatar.tsx +13 -2
- package/src/components/shared/connector-platform-icon.tsx +4 -0
- package/src/components/shared/settings/section-heartbeat.tsx +1 -1
- package/src/components/shared/settings/section-orchestrator.tsx +1 -2
- package/src/components/shared/settings/section-web-search.tsx +56 -0
- package/src/components/shared/settings/settings-page.tsx +74 -0
- package/src/components/skills/skill-list.tsx +2 -1
- package/src/components/tasks/task-board.tsx +1 -1
- package/src/components/tasks/task-list.tsx +5 -2
- package/src/components/tasks/task-sheet.tsx +12 -12
- package/src/hooks/use-continuous-speech.ts +181 -0
- package/src/hooks/use-openclaw-gateway.ts +63 -0
- package/src/hooks/use-view-router.ts +52 -0
- package/src/hooks/use-voice-conversation.ts +80 -0
- package/src/lib/id.ts +6 -0
- package/src/lib/notification-sounds.ts +58 -0
- package/src/lib/personality-parser.ts +97 -0
- package/src/lib/projects.ts +13 -0
- package/src/lib/provider-sets.ts +5 -0
- package/src/lib/providers/anthropic.ts +14 -1
- package/src/lib/providers/index.ts +6 -0
- package/src/lib/providers/ollama.ts +9 -1
- package/src/lib/providers/openai.ts +9 -1
- package/src/lib/providers/openclaw.ts +28 -2
- package/src/lib/runtime-loop.ts +2 -2
- package/src/lib/server/api-routes.test.ts +5 -6
- package/src/lib/server/build-llm.ts +17 -4
- package/src/lib/server/chat-execution.ts +82 -6
- package/src/lib/server/collection-helpers.ts +54 -0
- package/src/lib/server/connectors/bluebubbles.test.ts +217 -0
- package/src/lib/server/connectors/bluebubbles.ts +360 -0
- package/src/lib/server/connectors/connector-routing.test.ts +1 -1
- package/src/lib/server/connectors/googlechat.ts +51 -8
- package/src/lib/server/connectors/manager.ts +424 -13
- package/src/lib/server/connectors/media.ts +2 -2
- package/src/lib/server/connectors/openclaw.ts +65 -0
- package/src/lib/server/connectors/pairing.test.ts +99 -0
- package/src/lib/server/connectors/pairing.ts +256 -0
- package/src/lib/server/connectors/signal.ts +1 -0
- package/src/lib/server/connectors/teams.ts +5 -5
- package/src/lib/server/connectors/types.ts +10 -0
- package/src/lib/server/daemon-state.ts +11 -0
- package/src/lib/server/execution-log.ts +3 -3
- package/src/lib/server/heartbeat-service.ts +1 -1
- package/src/lib/server/knowledge-db.test.ts +2 -33
- package/src/lib/server/main-agent-loop.ts +8 -9
- package/src/lib/server/main-session.ts +21 -0
- package/src/lib/server/memory-db.ts +6 -6
- package/src/lib/server/openclaw-approvals.ts +105 -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 +497 -0
- package/src/lib/server/orchestrator-lg.ts +30 -9
- package/src/lib/server/orchestrator.ts +4 -4
- package/src/lib/server/process-manager.ts +2 -2
- package/src/lib/server/queue.ts +24 -11
- package/src/lib/server/scheduler.ts +2 -2
- package/src/lib/server/session-mailbox.ts +2 -2
- package/src/lib/server/session-run-manager.ts +2 -2
- package/src/lib/server/session-tools/connector.ts +53 -6
- package/src/lib/server/session-tools/crud.ts +3 -3
- package/src/lib/server/session-tools/delegate.ts +22 -6
- package/src/lib/server/session-tools/file.ts +192 -19
- package/src/lib/server/session-tools/index.ts +4 -2
- package/src/lib/server/session-tools/memory.ts +2 -2
- package/src/lib/server/session-tools/openclaw-nodes.ts +112 -0
- package/src/lib/server/session-tools/sandbox.ts +33 -0
- package/src/lib/server/session-tools/search-providers.ts +277 -0
- package/src/lib/server/session-tools/session-info.ts +2 -2
- 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 +53 -72
- package/src/lib/server/storage.ts +74 -11
- package/src/lib/server/stream-agent-chat.ts +53 -4
- package/src/lib/server/suggestions.ts +20 -0
- package/src/lib/server/task-result.test.ts +44 -0
- package/src/lib/server/task-result.ts +14 -0
- package/src/lib/server/ws-hub.ts +14 -0
- package/src/lib/tool-definitions.ts +5 -3
- package/src/lib/tts-stream.ts +130 -0
- package/src/lib/view-routes.ts +28 -0
- package/src/proxy.ts +3 -0
- package/src/stores/use-app-store.ts +80 -1
- package/src/stores/use-approval-store.ts +78 -0
- package/src/stores/use-chat-store.ts +162 -6
- package/src/types/index.ts +154 -3
- package/tsconfig.json +13 -4
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useEffect, useState, useMemo } from 'react'
|
|
3
|
+
import { useEffect, useState, useMemo, useRef } from 'react'
|
|
4
4
|
import type { Session } from '@/types'
|
|
5
5
|
import { useAppStore } from '@/stores/use-app-store'
|
|
6
6
|
import { useChatStore } from '@/stores/use-chat-store'
|
|
@@ -13,11 +13,22 @@ import {
|
|
|
13
13
|
CONNECTOR_PLATFORM_META,
|
|
14
14
|
getSessionConnector,
|
|
15
15
|
} from '@/components/shared/connector-platform-icon'
|
|
16
|
+
import { AgentAvatar } from '@/components/agents/agent-avatar'
|
|
16
17
|
|
|
17
18
|
function shortPath(p: string): string {
|
|
18
19
|
return (p || '').replace(/^\/Users\/\w+/, '~')
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
function formatDuration(sec: number): string {
|
|
23
|
+
if (sec >= 3600) {
|
|
24
|
+
const h = Math.floor(sec / 3600)
|
|
25
|
+
const m = Math.floor((sec % 3600) / 60)
|
|
26
|
+
return m > 0 ? `${h}h${m}m` : `${h}h`
|
|
27
|
+
}
|
|
28
|
+
if (sec >= 60) return `${Math.floor(sec / 60)}m`
|
|
29
|
+
return `${sec}s`
|
|
30
|
+
}
|
|
31
|
+
|
|
21
32
|
const PROVIDER_LABELS: Record<string, string> = {
|
|
22
33
|
'claude-cli': 'CLI',
|
|
23
34
|
openai: 'OpenAI',
|
|
@@ -34,14 +45,20 @@ interface Props {
|
|
|
34
45
|
mobile?: boolean
|
|
35
46
|
browserActive?: boolean
|
|
36
47
|
onStopBrowser?: () => void
|
|
48
|
+
onVoiceToggle?: () => void
|
|
49
|
+
voiceActive?: boolean
|
|
50
|
+
voiceSupported?: boolean
|
|
37
51
|
}
|
|
38
52
|
|
|
39
|
-
export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, mobile, browserActive, onStopBrowser }: Props) {
|
|
53
|
+
export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, mobile, browserActive, onStopBrowser, onVoiceToggle, voiceActive, voiceSupported }: Props) {
|
|
40
54
|
const ttsEnabled = useChatStore((s) => s.ttsEnabled)
|
|
41
55
|
const toggleTts = useChatStore((s) => s.toggleTts)
|
|
56
|
+
const soundEnabled = useChatStore((s) => s.soundEnabled)
|
|
57
|
+
const toggleSound = useChatStore((s) => s.toggleSound)
|
|
42
58
|
const debugOpen = useChatStore((s) => s.debugOpen)
|
|
43
59
|
const setDebugOpen = useChatStore((s) => s.setDebugOpen)
|
|
44
60
|
const lastUsage = useChatStore((s) => s.lastUsage)
|
|
61
|
+
const agentStatus = useChatStore((s) => s.agentStatus)
|
|
45
62
|
const agents = useAppStore((s) => s.agents)
|
|
46
63
|
const tasks = useAppStore((s) => s.tasks)
|
|
47
64
|
const setActiveView = useAppStore((s) => s.setActiveView)
|
|
@@ -49,18 +66,26 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
49
66
|
const setSidebarOpen = useAppStore((s) => s.setSidebarOpen)
|
|
50
67
|
const appSettings = useAppStore((s) => s.appSettings)
|
|
51
68
|
const loadSessions = useAppStore((s) => s.loadSessions)
|
|
69
|
+
const loadAgents = useAppStore((s) => s.loadAgents)
|
|
70
|
+
const inspectorOpen = useAppStore((s) => s.inspectorOpen)
|
|
71
|
+
const setInspectorOpen = useAppStore((s) => s.setInspectorOpen)
|
|
52
72
|
const connectors = useAppStore((s) => s.connectors)
|
|
53
73
|
const loadConnectors = useAppStore((s) => s.loadConnectors)
|
|
54
74
|
const providerLabel = PROVIDER_LABELS[session.provider] || session.provider
|
|
55
75
|
const agent = session.agentId ? agents[session.agentId] : null
|
|
56
76
|
const connector = getSessionConnector(session, connectors)
|
|
57
77
|
const connectorMeta = connector ? CONNECTOR_PLATFORM_META[connector.platform] : null
|
|
78
|
+
const connectorPresence = connector?.presence
|
|
58
79
|
const modelName = session.model || agent?.model || ''
|
|
59
80
|
const [copied, setCopied] = useState(false)
|
|
60
81
|
const [heartbeatSaving, setHeartbeatSaving] = useState(false)
|
|
82
|
+
const [hbDropdownOpen, setHbDropdownOpen] = useState(false)
|
|
83
|
+
const hbDropdownRef = useRef<HTMLDivElement>(null)
|
|
61
84
|
const [mainLoopSaving, setMainLoopSaving] = useState(false)
|
|
62
85
|
const [mainLoopError, setMainLoopError] = useState('')
|
|
63
86
|
const [mainLoopNotice, setMainLoopNotice] = useState('')
|
|
87
|
+
const [syncingHistory, setSyncingHistory] = useState(false)
|
|
88
|
+
const [syncResult, setSyncResult] = useState('')
|
|
64
89
|
|
|
65
90
|
// Find linked task for this session
|
|
66
91
|
const linkedTask = useMemo(() => {
|
|
@@ -102,11 +127,53 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
102
127
|
setTimeout(() => setCopied(false), 2000)
|
|
103
128
|
}
|
|
104
129
|
|
|
105
|
-
const heartbeatEnabled = session.heartbeatEnabled !== false
|
|
106
130
|
const heartbeatSupported = (session.tools?.length ?? 0) > 0
|
|
107
131
|
const loopIsOngoing = appSettings.loopMode === 'ongoing'
|
|
108
|
-
const
|
|
109
|
-
|
|
132
|
+
const { heartbeatEnabled, heartbeatIntervalSec, heartbeatExplicitOptIn } = useMemo(() => {
|
|
133
|
+
// Resolve through the same cascade as the backend: settings → agent → session
|
|
134
|
+
const parseDur = (v: unknown): number | null => {
|
|
135
|
+
if (v === null || v === undefined) return null
|
|
136
|
+
if (typeof v === 'number') return Number.isFinite(v) ? Math.max(0, Math.min(86400, Math.trunc(v))) : null
|
|
137
|
+
if (typeof v !== 'string') return null
|
|
138
|
+
const t = v.trim().toLowerCase()
|
|
139
|
+
if (!t) return null
|
|
140
|
+
const n = Number(t)
|
|
141
|
+
if (Number.isFinite(n)) return Math.max(0, Math.min(86400, Math.trunc(n)))
|
|
142
|
+
const m = t.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?$/)
|
|
143
|
+
if (!m || (!m[1] && !m[2] && !m[3])) return null
|
|
144
|
+
const total = (m[1] ? parseInt(m[1]) * 3600 : 0) + (m[2] ? parseInt(m[2]) * 60 : 0) + (m[3] ? parseInt(m[3]) : 0)
|
|
145
|
+
return Math.max(0, Math.min(86400, total))
|
|
146
|
+
}
|
|
147
|
+
const resolveFrom = (obj: { heartbeatInterval?: string | number | null; heartbeatIntervalSec?: number | null }): number | null => {
|
|
148
|
+
const dur = parseDur(obj.heartbeatInterval)
|
|
149
|
+
if (dur !== null) return dur
|
|
150
|
+
const sec = parseDur(obj.heartbeatIntervalSec)
|
|
151
|
+
if (sec !== null) return sec
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
154
|
+
// Global defaults
|
|
155
|
+
let sec = resolveFrom(appSettings) ?? 1800
|
|
156
|
+
let enabled = sec > 0
|
|
157
|
+
let explicitOptIn = false
|
|
158
|
+
// Agent layer
|
|
159
|
+
if (agent) {
|
|
160
|
+
if (agent.heartbeatEnabled === false) enabled = false
|
|
161
|
+
if (agent.heartbeatEnabled === true) { enabled = true; explicitOptIn = true }
|
|
162
|
+
sec = resolveFrom(agent) ?? sec
|
|
163
|
+
}
|
|
164
|
+
// Session layer — only applies for non-agent chats (agent chats save directly to agent)
|
|
165
|
+
if (!agent) {
|
|
166
|
+
if (session.heartbeatEnabled === false) enabled = false
|
|
167
|
+
if (session.heartbeatEnabled === true) { enabled = true; explicitOptIn = true }
|
|
168
|
+
sec = resolveFrom(session) ?? sec
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
heartbeatEnabled: enabled && sec > 0,
|
|
172
|
+
heartbeatIntervalSec: sec,
|
|
173
|
+
heartbeatExplicitOptIn: explicitOptIn,
|
|
174
|
+
}
|
|
175
|
+
}, [appSettings, agent, session])
|
|
176
|
+
const heartbeatWillRun = heartbeatEnabled && (loopIsOngoing || heartbeatExplicitOptIn)
|
|
110
177
|
const isMainSession = session.name === '__main__'
|
|
111
178
|
const missionState = session.mainLoopState || {}
|
|
112
179
|
const missionPaused = missionState.paused === true
|
|
@@ -119,23 +186,40 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
119
186
|
if (!heartbeatSupported || heartbeatSaving) return
|
|
120
187
|
setHeartbeatSaving(true)
|
|
121
188
|
try {
|
|
122
|
-
|
|
123
|
-
|
|
189
|
+
const next = !heartbeatEnabled
|
|
190
|
+
if (session.agentId) {
|
|
191
|
+
await api('PUT', `/agents/${session.agentId}`, { heartbeatEnabled: next })
|
|
192
|
+
// Clear any stale session-level override so the agent value wins
|
|
193
|
+
await api('PUT', `/sessions/${session.id}`, { heartbeatEnabled: null })
|
|
194
|
+
await Promise.all([loadAgents(), loadSessions()])
|
|
195
|
+
} else {
|
|
196
|
+
await api('PUT', `/sessions/${session.id}`, { heartbeatEnabled: next })
|
|
197
|
+
await loadSessions()
|
|
198
|
+
}
|
|
124
199
|
} finally {
|
|
125
200
|
setHeartbeatSaving(false)
|
|
126
201
|
}
|
|
127
202
|
}
|
|
128
203
|
|
|
129
|
-
const
|
|
204
|
+
const handleSelectHeartbeatInterval = async (sec: number) => {
|
|
130
205
|
if (!heartbeatSupported || heartbeatSaving) return
|
|
131
|
-
|
|
132
|
-
const current = heartbeatIntervalSec
|
|
133
|
-
const idx = presets.indexOf(current)
|
|
134
|
-
const next = idx === -1 ? 120 : presets[(idx + 1) % presets.length]
|
|
206
|
+
setHbDropdownOpen(false)
|
|
135
207
|
setHeartbeatSaving(true)
|
|
136
208
|
try {
|
|
137
|
-
|
|
138
|
-
|
|
209
|
+
if (session.agentId) {
|
|
210
|
+
// Save to agent with both formats so the cascade resolves correctly
|
|
211
|
+
await api('PUT', `/agents/${session.agentId}`, {
|
|
212
|
+
heartbeatInterval: formatDuration(sec),
|
|
213
|
+
heartbeatIntervalSec: sec,
|
|
214
|
+
heartbeatEnabled: true,
|
|
215
|
+
})
|
|
216
|
+
// Clear stale session-level overrides
|
|
217
|
+
await api('PUT', `/sessions/${session.id}`, { heartbeatIntervalSec: null, heartbeatEnabled: null })
|
|
218
|
+
await Promise.all([loadAgents(), loadSessions()])
|
|
219
|
+
} else {
|
|
220
|
+
await api('PUT', `/sessions/${session.id}`, { heartbeatIntervalSec: sec, heartbeatEnabled: true })
|
|
221
|
+
await loadSessions()
|
|
222
|
+
}
|
|
139
223
|
} finally {
|
|
140
224
|
setHeartbeatSaving(false)
|
|
141
225
|
}
|
|
@@ -193,6 +277,52 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
193
277
|
void postMainLoopAction('clear_events')
|
|
194
278
|
}
|
|
195
279
|
|
|
280
|
+
const isOpenClawAgent = agent?.provider === 'openclaw'
|
|
281
|
+
// Derive OpenClaw session key: agent sessions use "agent:<name>:main" convention
|
|
282
|
+
const openclawSessionKey = isOpenClawAgent && agent
|
|
283
|
+
? `agent:${agent.name.toLowerCase().replace(/\s+/g, '-')}:main`
|
|
284
|
+
: null
|
|
285
|
+
|
|
286
|
+
const handleSyncHistory = async () => {
|
|
287
|
+
if (!openclawSessionKey || syncingHistory) return
|
|
288
|
+
setSyncingHistory(true)
|
|
289
|
+
setSyncResult('')
|
|
290
|
+
try {
|
|
291
|
+
const preview = await api<{ sessionKey: string; epoch: number; messages: Array<{ role: string; content: string; ts: number }> }>(
|
|
292
|
+
'GET', `/openclaw/history?sessionKey=${encodeURIComponent(openclawSessionKey)}`,
|
|
293
|
+
)
|
|
294
|
+
if (!preview?.messages?.length) {
|
|
295
|
+
setSyncResult('No new messages found.')
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
const result = await api<{ ok: boolean; merged: number }>(
|
|
299
|
+
'POST', '/openclaw/history',
|
|
300
|
+
{ sessionKey: openclawSessionKey, epoch: preview.epoch, localSessionId: session.id },
|
|
301
|
+
)
|
|
302
|
+
setSyncResult(result.merged > 0 ? `Synced ${result.merged} message${result.merged !== 1 ? 's' : ''}.` : 'Already up to date.')
|
|
303
|
+
if (result.merged > 0) await loadSessions()
|
|
304
|
+
} catch (err: unknown) {
|
|
305
|
+
setSyncResult(err instanceof Error ? err.message : 'Sync failed.')
|
|
306
|
+
} finally {
|
|
307
|
+
setSyncingHistory(false)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
useEffect(() => {
|
|
312
|
+
if (!syncResult) return
|
|
313
|
+
const timer = setTimeout(() => setSyncResult(''), 3000)
|
|
314
|
+
return () => clearTimeout(timer)
|
|
315
|
+
}, [syncResult])
|
|
316
|
+
|
|
317
|
+
useEffect(() => {
|
|
318
|
+
if (!hbDropdownOpen) return
|
|
319
|
+
const handler = (e: MouseEvent) => {
|
|
320
|
+
if (hbDropdownRef.current && !hbDropdownRef.current.contains(e.target as Node)) setHbDropdownOpen(false)
|
|
321
|
+
}
|
|
322
|
+
document.addEventListener('mousedown', handler)
|
|
323
|
+
return () => document.removeEventListener('mousedown', handler)
|
|
324
|
+
}, [hbDropdownOpen])
|
|
325
|
+
|
|
196
326
|
useEffect(() => {
|
|
197
327
|
if (session.name.startsWith('connector:')) {
|
|
198
328
|
void loadConnectors()
|
|
@@ -223,6 +353,7 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
223
353
|
)}
|
|
224
354
|
<div className="flex-1 min-w-0">
|
|
225
355
|
<div className="flex items-center gap-2.5">
|
|
356
|
+
{agent && <AgentAvatar seed={agent.avatarSeed} name={agent.name} size={24} />}
|
|
226
357
|
<span className="font-display text-[16px] font-600 block truncate tracking-[-0.02em]">{
|
|
227
358
|
session.name === '__main__' ? 'Main Chat'
|
|
228
359
|
: session.name.startsWith('agent-thread:') ? (agent?.name || session.name)
|
|
@@ -242,6 +373,27 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
242
373
|
{connectorMeta.label}
|
|
243
374
|
</span>
|
|
244
375
|
)}
|
|
376
|
+
{connector && connectorPresence && (() => {
|
|
377
|
+
const lastAt = connectorPresence.lastMessageAt
|
|
378
|
+
if (!lastAt) return (
|
|
379
|
+
<span className="shrink-0 inline-flex items-center gap-1 text-[10px] text-text-3/50">
|
|
380
|
+
<span className="w-1.5 h-1.5 rounded-full bg-text-3/40" />
|
|
381
|
+
Inactive
|
|
382
|
+
</span>
|
|
383
|
+
)
|
|
384
|
+
const ago = Date.now() - lastAt
|
|
385
|
+
const isActive = ago < 5 * 60_000
|
|
386
|
+
const isRecent = ago < 30 * 60_000
|
|
387
|
+
const label = isActive ? 'Active' : isRecent ? `${Math.floor(ago / 60_000)}m ago` : 'Inactive'
|
|
388
|
+
const dotColor = isActive ? 'bg-emerald-400' : isRecent ? 'bg-amber-400' : 'bg-text-3/40'
|
|
389
|
+
const textColor = isActive ? 'text-emerald-400' : isRecent ? 'text-amber-300' : 'text-text-3/50'
|
|
390
|
+
return (
|
|
391
|
+
<span className={`shrink-0 inline-flex items-center gap-1 text-[10px] ${textColor}`}>
|
|
392
|
+
<span className={`w-1.5 h-1.5 rounded-full ${dotColor}`} />
|
|
393
|
+
{label}
|
|
394
|
+
</span>
|
|
395
|
+
)
|
|
396
|
+
})()}
|
|
245
397
|
{session.provider && session.provider !== 'claude-cli' && (
|
|
246
398
|
<span className="shrink-0 px-2.5 py-0.5 rounded-[7px] bg-accent-soft text-accent-bright text-[10px] font-700 uppercase tracking-wider">
|
|
247
399
|
{providerLabel}
|
|
@@ -267,6 +419,21 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
267
419
|
<>
|
|
268
420
|
<span className="text-[11px] text-text-3/60">·</span>
|
|
269
421
|
<span className="text-[11px] text-text-3/50 font-mono truncate shrink-0">{modelName}</span>
|
|
422
|
+
{session.conversationTone && session.conversationTone !== 'neutral' && (() => {
|
|
423
|
+
const toneColors: Record<string, string> = {
|
|
424
|
+
formal: 'bg-[#3B82F6]',
|
|
425
|
+
casual: 'bg-emerald-400',
|
|
426
|
+
empathetic: 'bg-purple-400',
|
|
427
|
+
technical: 'bg-[#F59E0B]',
|
|
428
|
+
}
|
|
429
|
+
const color = toneColors[session.conversationTone] || ''
|
|
430
|
+
return color ? (
|
|
431
|
+
<span
|
|
432
|
+
className={`w-2 h-2 rounded-full shrink-0 ${color}`}
|
|
433
|
+
title={`Tone: ${session.conversationTone}`}
|
|
434
|
+
/>
|
|
435
|
+
) : null
|
|
436
|
+
})()}
|
|
270
437
|
</>
|
|
271
438
|
)}
|
|
272
439
|
{lastUsage && !streaming && (
|
|
@@ -276,6 +443,50 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
276
443
|
</>
|
|
277
444
|
)}
|
|
278
445
|
</div>
|
|
446
|
+
{(() => {
|
|
447
|
+
const liveStatus = agentStatus || (missionState.status ? {
|
|
448
|
+
goal: missionState.goal ?? undefined,
|
|
449
|
+
status: missionState.status ?? undefined,
|
|
450
|
+
summary: missionState.summary ?? undefined,
|
|
451
|
+
nextAction: missionState.nextAction ?? undefined,
|
|
452
|
+
} : null)
|
|
453
|
+
if (!liveStatus) return null
|
|
454
|
+
const statusColors: Record<string, string> = {
|
|
455
|
+
idle: 'bg-text-3/40',
|
|
456
|
+
progress: 'bg-[#3B82F6]',
|
|
457
|
+
blocked: 'bg-amber-400',
|
|
458
|
+
ok: 'bg-emerald-400',
|
|
459
|
+
}
|
|
460
|
+
const dotColor = statusColors[liveStatus.status || ''] || 'bg-text-3/40'
|
|
461
|
+
return (
|
|
462
|
+
<div className="flex items-center gap-2 mt-0.5">
|
|
463
|
+
{liveStatus.goal && (
|
|
464
|
+
<span className="text-[10px] text-text-3/60 font-mono truncate max-w-[240px]" title={liveStatus.goal}>
|
|
465
|
+
{liveStatus.goal}
|
|
466
|
+
</span>
|
|
467
|
+
)}
|
|
468
|
+
{liveStatus.status && (
|
|
469
|
+
<span className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-[5px] text-[9px] font-700 uppercase tracking-wider ${
|
|
470
|
+
liveStatus.status === 'blocked' ? 'bg-amber-400/15 text-amber-300'
|
|
471
|
+
: liveStatus.status === 'ok' ? 'bg-emerald-400/15 text-emerald-400'
|
|
472
|
+
: liveStatus.status === 'progress' ? 'bg-[#3B82F6]/15 text-[#60A5FA]'
|
|
473
|
+
: 'bg-white/[0.04] text-text-3/60'
|
|
474
|
+
}`}>
|
|
475
|
+
<span className={`w-1.5 h-1.5 rounded-full ${dotColor}`} />
|
|
476
|
+
{liveStatus.status}
|
|
477
|
+
</span>
|
|
478
|
+
)}
|
|
479
|
+
{liveStatus.nextAction && (
|
|
480
|
+
<>
|
|
481
|
+
<span className="text-[10px] text-text-3/40">→</span>
|
|
482
|
+
<span className="text-[10px] text-text-3/50 font-mono truncate max-w-[200px]" title={liveStatus.nextAction}>
|
|
483
|
+
{liveStatus.nextAction}
|
|
484
|
+
</span>
|
|
485
|
+
</>
|
|
486
|
+
)}
|
|
487
|
+
</div>
|
|
488
|
+
)
|
|
489
|
+
})()}
|
|
279
490
|
</div>
|
|
280
491
|
<div className="flex gap-1.5">
|
|
281
492
|
{streaming && (
|
|
@@ -285,6 +496,14 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
285
496
|
</svg>
|
|
286
497
|
</IconButton>
|
|
287
498
|
)}
|
|
499
|
+
{agent && (
|
|
500
|
+
<IconButton onClick={() => setInspectorOpen(!inspectorOpen)} active={inspectorOpen} aria-label="Toggle inspector panel">
|
|
501
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
502
|
+
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
|
503
|
+
<circle cx="12" cy="12" r="3" />
|
|
504
|
+
</svg>
|
|
505
|
+
</IconButton>
|
|
506
|
+
)}
|
|
288
507
|
<IconButton onClick={() => setDebugOpen(!debugOpen)} active={debugOpen} aria-label="Toggle debug panel">
|
|
289
508
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
290
509
|
<path d="M12 20V10" />
|
|
@@ -292,13 +511,28 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
292
511
|
<path d="M6 20v-4" />
|
|
293
512
|
</svg>
|
|
294
513
|
</IconButton>
|
|
514
|
+
<IconButton onClick={toggleSound} active={soundEnabled} aria-label="Toggle sound notifications">
|
|
515
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
516
|
+
<path d="M18 8A6 6 0 0 1 18 16" />
|
|
517
|
+
<path d="M13 2L8 7H4a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h4l5 5V2z" />
|
|
518
|
+
</svg>
|
|
519
|
+
</IconButton>
|
|
295
520
|
<IconButton onClick={toggleTts} active={ttsEnabled} aria-label="Toggle text-to-speech">
|
|
296
521
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
297
522
|
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
|
298
523
|
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
|
299
524
|
</svg>
|
|
300
525
|
</IconButton>
|
|
301
|
-
|
|
526
|
+
{voiceSupported && onVoiceToggle && (
|
|
527
|
+
<IconButton onClick={onVoiceToggle} active={voiceActive} aria-label="Toggle voice conversation">
|
|
528
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
529
|
+
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
|
|
530
|
+
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
|
531
|
+
<line x1="12" x2="12" y1="19" y2="22" />
|
|
532
|
+
</svg>
|
|
533
|
+
</IconButton>
|
|
534
|
+
)}
|
|
535
|
+
<IconButton onClick={(e) => { e.stopPropagation(); onMenuToggle() }} aria-label="Chat menu">
|
|
302
536
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
|
303
537
|
<circle cx="12" cy="6" r="1" />
|
|
304
538
|
<circle cx="12" cy="12" r="1" />
|
|
@@ -320,25 +554,44 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
320
554
|
onClick={handleToggleHeartbeat}
|
|
321
555
|
disabled={heartbeatSaving}
|
|
322
556
|
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] transition-colors cursor-pointer border-none
|
|
323
|
-
${
|
|
324
|
-
title={
|
|
557
|
+
${heartbeatWillRun ? 'bg-emerald-500/10 hover:bg-emerald-500/15 text-emerald-400' : 'bg-white/[0.04] hover:bg-white/[0.07] text-text-3'}`}
|
|
558
|
+
title={heartbeatWillRun ? 'Toggle heartbeat' : !heartbeatEnabled ? 'Heartbeat disabled — click to enable' : 'Heartbeat enabled but paused (bounded loop mode, no explicit opt-in)'}
|
|
325
559
|
>
|
|
326
|
-
<span className={`w-1.5 h-1.5 rounded-full ${
|
|
560
|
+
<span className={`w-1.5 h-1.5 rounded-full ${heartbeatWillRun ? 'bg-emerald-400' : 'bg-text-3/40'}`} />
|
|
327
561
|
<span className="text-[11px] font-600">
|
|
328
|
-
HB {
|
|
562
|
+
HB {heartbeatWillRun ? 'On' : 'Off'}
|
|
329
563
|
</span>
|
|
330
|
-
{!loopIsOngoing && (
|
|
564
|
+
{heartbeatEnabled && !loopIsOngoing && !heartbeatExplicitOptIn && (
|
|
331
565
|
<span className="text-[10px] text-text-3/50">(bounded)</span>
|
|
332
566
|
)}
|
|
333
567
|
</button>
|
|
334
|
-
<
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
568
|
+
<div className="relative" ref={hbDropdownRef}>
|
|
569
|
+
<button
|
|
570
|
+
onClick={() => setHbDropdownOpen((o) => !o)}
|
|
571
|
+
disabled={heartbeatSaving}
|
|
572
|
+
className="flex items-center gap-1 px-2.5 py-1 rounded-[8px] bg-white/[0.04] hover:bg-white/[0.07] text-text-3 transition-colors cursor-pointer border-none"
|
|
573
|
+
title="Set heartbeat interval"
|
|
574
|
+
>
|
|
575
|
+
<span className="text-[11px] font-600">{formatDuration(heartbeatIntervalSec)}</span>
|
|
576
|
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="text-text-3/50">
|
|
577
|
+
<polyline points="6 9 12 15 18 9" />
|
|
578
|
+
</svg>
|
|
579
|
+
</button>
|
|
580
|
+
{hbDropdownOpen && (
|
|
581
|
+
<div className="absolute top-full left-0 mt-1 py-1 rounded-[10px] border border-white/[0.06] bg-bg/95 backdrop-blur-md shadow-lg z-50 min-w-[80px]">
|
|
582
|
+
{[30, 60, 120, 300, 600, 1800, 3600].map((sec) => (
|
|
583
|
+
<button
|
|
584
|
+
key={sec}
|
|
585
|
+
onClick={() => handleSelectHeartbeatInterval(sec)}
|
|
586
|
+
className={`w-full text-left px-3 py-1.5 text-[11px] font-600 transition-colors cursor-pointer border-none
|
|
587
|
+
${sec === heartbeatIntervalSec ? 'bg-accent-soft text-accent-bright' : 'text-text-3 hover:bg-white/[0.06]'}`}
|
|
588
|
+
>
|
|
589
|
+
{formatDuration(sec)}
|
|
590
|
+
</button>
|
|
591
|
+
))}
|
|
592
|
+
</div>
|
|
593
|
+
)}
|
|
594
|
+
</div>
|
|
342
595
|
</>
|
|
343
596
|
)}
|
|
344
597
|
{isMainSession && (
|
|
@@ -427,6 +680,29 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
427
680
|
</span>
|
|
428
681
|
</button>
|
|
429
682
|
)}
|
|
683
|
+
{isOpenClawAgent && openclawSessionKey && (
|
|
684
|
+
<>
|
|
685
|
+
<button
|
|
686
|
+
onClick={handleSyncHistory}
|
|
687
|
+
disabled={syncingHistory}
|
|
688
|
+
className="flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] bg-indigo-500/10 hover:bg-indigo-500/15 transition-colors cursor-pointer border-none disabled:opacity-50"
|
|
689
|
+
title="Sync chat history from OpenClaw gateway"
|
|
690
|
+
>
|
|
691
|
+
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="text-indigo-400">
|
|
692
|
+
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
|
693
|
+
<path d="M3 3v5h5" />
|
|
694
|
+
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
|
|
695
|
+
<path d="M16 16h5v5" />
|
|
696
|
+
</svg>
|
|
697
|
+
<span className="text-[11px] font-600 text-indigo-400">
|
|
698
|
+
{syncingHistory ? 'Syncing...' : 'Sync History'}
|
|
699
|
+
</span>
|
|
700
|
+
</button>
|
|
701
|
+
{syncResult && (
|
|
702
|
+
<span className="text-[10px] text-emerald-300/90">{syncResult}</span>
|
|
703
|
+
)}
|
|
704
|
+
</>
|
|
705
|
+
)}
|
|
430
706
|
{linkedTask && (
|
|
431
707
|
<button
|
|
432
708
|
onClick={() => setActiveView('tasks')}
|
|
@@ -467,7 +743,7 @@ export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, m
|
|
|
467
743
|
<button
|
|
468
744
|
onClick={onStopBrowser}
|
|
469
745
|
className="flex items-center gap-1.5 px-2.5 py-1 rounded-[8px] bg-[#3B82F6]/10 hover:bg-[#F43F5E]/15 transition-colors cursor-pointer group"
|
|
470
|
-
title="Stop browser
|
|
746
|
+
title="Stop browser"
|
|
471
747
|
>
|
|
472
748
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="text-[#3B82F6] group-hover:text-[#F43F5E]">
|
|
473
749
|
<rect x="3" y="3" width="18" height="14" rx="2" />
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef, useState } from 'react'
|
|
4
|
+
import { CodeBlock } from './code-block'
|
|
5
|
+
|
|
6
|
+
interface PreviewContent {
|
|
7
|
+
type: 'browser' | 'image' | 'code' | 'html'
|
|
8
|
+
url?: string
|
|
9
|
+
content?: string
|
|
10
|
+
title?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
content: PreviewContent
|
|
15
|
+
onClose: () => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function ChatPreviewPanel({ content, onClose }: Props) {
|
|
19
|
+
const [width, setWidth] = useState(400)
|
|
20
|
+
const dragging = useRef(false)
|
|
21
|
+
const startX = useRef(0)
|
|
22
|
+
const startWidth = useRef(400)
|
|
23
|
+
|
|
24
|
+
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
25
|
+
e.preventDefault()
|
|
26
|
+
dragging.current = true
|
|
27
|
+
startX.current = e.clientX
|
|
28
|
+
startWidth.current = width
|
|
29
|
+
|
|
30
|
+
const handleMouseMove = (ev: MouseEvent) => {
|
|
31
|
+
if (!dragging.current) return
|
|
32
|
+
const diff = startX.current - ev.clientX
|
|
33
|
+
const next = Math.max(300, Math.min(window.innerWidth * 0.5, startWidth.current + diff))
|
|
34
|
+
setWidth(next)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const handleMouseUp = () => {
|
|
38
|
+
dragging.current = false
|
|
39
|
+
document.removeEventListener('mousemove', handleMouseMove)
|
|
40
|
+
document.removeEventListener('mouseup', handleMouseUp)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
document.addEventListener('mousemove', handleMouseMove)
|
|
44
|
+
document.addEventListener('mouseup', handleMouseUp)
|
|
45
|
+
}, [width])
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div
|
|
49
|
+
className="flex flex-col border-l border-white/[0.06] bg-bg shrink-0"
|
|
50
|
+
style={{ width, minWidth: 300, maxWidth: '50%', animation: 'fade-in 0.25s ease' }}
|
|
51
|
+
>
|
|
52
|
+
{/* Resize handle */}
|
|
53
|
+
<div
|
|
54
|
+
className="absolute left-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-accent-bright/20 transition-colors z-10"
|
|
55
|
+
style={{ position: 'relative', width: 4, minWidth: 4 }}
|
|
56
|
+
onMouseDown={handleMouseDown}
|
|
57
|
+
/>
|
|
58
|
+
|
|
59
|
+
{/* Header */}
|
|
60
|
+
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-white/[0.06] shrink-0">
|
|
61
|
+
<span className="text-[12px] font-600 text-text-2 truncate flex-1">
|
|
62
|
+
{content.title || 'Preview'}
|
|
63
|
+
</span>
|
|
64
|
+
<button
|
|
65
|
+
onClick={onClose}
|
|
66
|
+
className="p-1 rounded-[6px] text-text-3 hover:text-text-2 hover:bg-white/[0.04] cursor-pointer border-none bg-transparent transition-colors"
|
|
67
|
+
aria-label="Close preview"
|
|
68
|
+
>
|
|
69
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
70
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
71
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
72
|
+
</svg>
|
|
73
|
+
</button>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
{/* Content */}
|
|
77
|
+
<div className="flex-1 overflow-auto min-h-0">
|
|
78
|
+
{content.type === 'browser' && content.url && (
|
|
79
|
+
<iframe
|
|
80
|
+
src={content.url}
|
|
81
|
+
className="w-full h-full border-none"
|
|
82
|
+
title={content.title || 'Browser Preview'}
|
|
83
|
+
sandbox="allow-scripts allow-same-origin"
|
|
84
|
+
/>
|
|
85
|
+
)}
|
|
86
|
+
{content.type === 'html' && content.content && (
|
|
87
|
+
<iframe
|
|
88
|
+
srcDoc={content.content}
|
|
89
|
+
className="w-full h-full border-none"
|
|
90
|
+
title={content.title || 'HTML Preview'}
|
|
91
|
+
sandbox="allow-scripts"
|
|
92
|
+
/>
|
|
93
|
+
)}
|
|
94
|
+
{content.type === 'image' && content.url && (
|
|
95
|
+
<div className="p-4 flex items-center justify-center h-full">
|
|
96
|
+
<img
|
|
97
|
+
src={content.url}
|
|
98
|
+
alt={content.title || 'Preview'}
|
|
99
|
+
className="max-w-full max-h-full rounded-[8px] object-contain"
|
|
100
|
+
/>
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
{content.type === 'code' && content.content && (
|
|
104
|
+
<div className="p-2">
|
|
105
|
+
<CodeBlock className={`language-${content.title?.split('.').pop() || 'text'}`}>
|
|
106
|
+
{content.content}
|
|
107
|
+
</CodeBlock>
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
)
|
|
113
|
+
}
|