@swarmclawai/swarmclaw 1.1.8 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +17 -0
  2. package/next.config.ts +0 -1
  3. package/package.json +3 -3
  4. package/src/app/activity/loading.tsx +5 -0
  5. package/src/app/api/agents/[id]/thread/route.ts +2 -1
  6. package/src/app/api/logs/route.ts +32 -5
  7. package/src/app/home/loading.tsx +5 -0
  8. package/src/app/logs/loading.tsx +5 -0
  9. package/src/app/memory/loading.tsx +5 -0
  10. package/src/app/tasks/loading.tsx +5 -0
  11. package/src/components/agents/agent-list.tsx +7 -12
  12. package/src/components/chat/chat-area.tsx +3 -3
  13. package/src/components/chat/chat-list.tsx +13 -2
  14. package/src/components/layout/sidebar-rail.tsx +14 -6
  15. package/src/components/memory/memory-graph-view.tsx +120 -68
  16. package/src/components/org-chart/mini-chat-bubble.tsx +58 -16
  17. package/src/components/shared/command-palette.tsx +35 -20
  18. package/src/components/shared/notification-center.tsx +1 -1
  19. package/src/hooks/use-app-bootstrap.ts +2 -1
  20. package/src/instrumentation.ts +27 -0
  21. package/src/lib/providers/anthropic.ts +14 -8
  22. package/src/lib/providers/openai.ts +3 -3
  23. package/src/lib/server/agents/agent-thread-session.ts +20 -14
  24. package/src/lib/server/chat-execution/continuation-evaluator.ts +2 -2
  25. package/src/lib/server/chat-execution/message-classifier.ts +2 -1
  26. package/src/lib/server/chat-execution/stream-agent-chat.ts +4 -19
  27. package/src/lib/server/chat-execution/stream-continuation.ts +4 -3
  28. package/src/lib/server/llm-response-cache.ts +2 -1
  29. package/src/lib/server/playwright-proxy.mjs +25 -6
  30. package/src/lib/server/runtime/run-ledger.ts +4 -4
  31. package/src/lib/server/runtime/scheduler.ts +9 -6
  32. package/src/lib/server/session-tools/skill-runtime.ts +10 -1
  33. package/src/lib/server/storage-cache.ts +2 -0
  34. package/src/lib/server/storage.ts +30 -3
  35. package/src/lib/server/tasks/task-quality-gate.test.ts +1 -1
  36. package/src/lib/server/tasks/task-quality-gate.ts +1 -1
  37. package/src/stores/slices/data-slice.ts +11 -0
  38. package/src/stores/slices/session-slice.ts +3 -0
package/README.md CHANGED
@@ -190,6 +190,23 @@ The building blocks are the same: **agents, tools, memory, delegation, schedules
190
190
 
191
191
  ## Release Notes
192
192
 
193
+ ### v1.1.9 Highlights
194
+
195
+ - **Docker build stability**: limit Next.js page data workers to 1 in build mode to prevent `SQLITE_BUSY` contention.
196
+ - **Async file I/O in providers**: Anthropic and OpenAI providers now use `fs.promises` for non-blocking attachment reads.
197
+ - **Anthropic request timeout**: 60s timeout on Anthropic API requests prevents indefinite hangs.
198
+ - **Graceful crash handling**: instrumentation now catches EPIPE and suppresses expected LangGraph unhandled rejections.
199
+ - **Log tail optimization**: `/api/logs` reads only the last 256 KB instead of loading the entire log file.
200
+ - **Thread session fast path**: `ensureAgentThreadSession` uses single-row lookup instead of full table scan when `threadSessionId` is set.
201
+ - **Memory graph performance**: force-directed simulation writes to DOM imperatively instead of re-rendering React state per frame; stops when kinetic energy settles.
202
+ - **Reduced polling frequency**: chat area WS polling intervals relaxed (messages/runs 2s to 10s, browser 5s to 30s) to lower server load.
203
+ - **Chat list indexing**: connector lookup indexed by `agentId` for O(1) instead of O(n) per session filter.
204
+ - **Sidebar skill badges**: skill draft count displayed as a badge on the Skills nav item.
205
+ - **Route loading states**: added `loading.tsx` skeleton pages for activity, home, logs, memory, and tasks routes.
206
+ - **Command palette cleanup**: fixed missing `setOpen` dependencies and removed unused props.
207
+ - **Playwright proxy hardening**: improved stdio pipe handling for dev server restarts.
208
+ - **Scheduler and run ledger fixes**: improved scheduler reliability and run ledger state tracking.
209
+
193
210
  ### v1.1.8 Highlights
194
211
 
195
212
  - **Agent live status**: real-time `/agents/:id/status` endpoint exposes goal, progress, and plan steps; org chart detail panel consumes it via `useAgentLiveStatus` hook.
package/next.config.ts CHANGED
@@ -58,7 +58,6 @@ const nextConfig: NextConfig = {
58
58
  root: PROJECT_ROOT,
59
59
  },
60
60
  experimental: {
61
- turbopackFileSystemCacheForDev: false,
62
61
  // Limit build workers to 1 inside Docker to avoid SQLITE_BUSY contention
63
62
  // when multiple workers collect page data concurrently.
64
63
  ...(process.env.SWARMCLAW_BUILD_MODE ? { cpus: 1 } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.1.8",
3
+ "version": "1.2.0",
4
4
  "description": "Self-hosted AI runtime for OpenClaw, delegation, autonomy, runtime skills, crypto wallets, and chat platform connectors.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -104,7 +104,7 @@
104
104
  "langchain": "^1.2.30",
105
105
  "lucide-react": "^0.574.0",
106
106
  "mailparser": "^3.9.3",
107
- "next": "16.1.6",
107
+ "next": "16.1.7",
108
108
  "next-themes": "^0.4.6",
109
109
  "nodemailer": "^8.0.1",
110
110
  "openclaw": "^2026.2.26",
@@ -140,7 +140,7 @@
140
140
  },
141
141
  "devDependencies": {
142
142
  "eslint": "^9",
143
- "eslint-config-next": "16.1.6",
143
+ "eslint-config-next": "16.1.7",
144
144
  "tsx": "^4.20.6"
145
145
  },
146
146
  "optionalDependencies": {
@@ -0,0 +1,5 @@
1
+ import { PageLoader } from '@/components/ui/page-loader'
2
+
3
+ export default function Loading() {
4
+ return <PageLoader label="Loading activity..." />
5
+ }
@@ -1,6 +1,7 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { buildAgentDisabledMessage, isAgentDisabled } from '@/lib/server/agents/agent-availability'
3
3
  import { ensureAgentThreadSession } from '@/lib/server/agents/agent-thread-session'
4
+ import type { Agent } from '@/types'
4
5
  import { loadAgents } from '@/lib/server/storage'
5
6
 
6
7
  export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
@@ -11,7 +12,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
11
12
  if (!agent) {
12
13
  return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
13
14
  }
14
- const session = ensureAgentThreadSession(agentId, user)
15
+ const session = ensureAgentThreadSession(agentId, user, agent as Agent)
15
16
  if (!session) {
16
17
  if (isAgentDisabled(agent)) {
17
18
  return NextResponse.json({ error: buildAgentDisabledMessage(agent, 'start new chats') }, { status: 409 })
@@ -2,6 +2,9 @@ import { NextResponse } from 'next/server'
2
2
  import fs from 'fs'
3
3
  import { APP_LOG_PATH } from '@/lib/server/data-dir'
4
4
 
5
+ /** Max bytes to read from the tail of the log file (256 KB). */
6
+ const TAIL_BYTES = 256 * 1024
7
+
5
8
  export async function GET(req: Request) {
6
9
  const { searchParams } = new URL(req.url)
7
10
  const lines = parseInt(searchParams.get('lines') || '200', 10)
@@ -13,7 +16,29 @@ export async function GET(req: Request) {
13
16
  return NextResponse.json({ entries: [], total: 0 })
14
17
  }
15
18
 
16
- const content = fs.readFileSync(APP_LOG_PATH, 'utf8')
19
+ const stat = fs.statSync(APP_LOG_PATH)
20
+ const fileSize = stat.size
21
+ if (fileSize === 0) {
22
+ return NextResponse.json({ entries: [], total: 0 })
23
+ }
24
+
25
+ // Read only the tail of the file to avoid loading multi-MB logs into memory
26
+ const readSize = Math.min(fileSize, TAIL_BYTES)
27
+ const buf = Buffer.alloc(readSize)
28
+ const fd = fs.openSync(APP_LOG_PATH, 'r')
29
+ try {
30
+ fs.readSync(fd, buf, 0, readSize, fileSize - readSize)
31
+ } finally {
32
+ fs.closeSync(fd)
33
+ }
34
+
35
+ let content = buf.toString('utf8')
36
+ // If we didn't read from the start, drop the first partial line
37
+ if (readSize < fileSize) {
38
+ const firstNewline = content.indexOf('\n')
39
+ if (firstNewline >= 0) content = content.slice(firstNewline + 1)
40
+ }
41
+
17
42
  let allLines = content.split('\n').filter(Boolean)
18
43
 
19
44
  // Filter by level
@@ -33,8 +58,9 @@ export async function GET(req: Request) {
33
58
  const entries = allLines.slice(-lines).reverse().map(parseLine)
34
59
 
35
60
  return NextResponse.json({ entries, total })
36
- } catch (err: any) {
37
- return NextResponse.json({ error: err.message }, { status: 500 })
61
+ } catch (err: unknown) {
62
+ const message = err instanceof Error ? err.message : String(err)
63
+ return NextResponse.json({ error: message }, { status: 500 })
38
64
  }
39
65
  }
40
66
 
@@ -44,8 +70,9 @@ export async function DELETE() {
44
70
  fs.writeFileSync(APP_LOG_PATH, '')
45
71
  }
46
72
  return NextResponse.json({ ok: true })
47
- } catch (err: any) {
48
- return NextResponse.json({ error: err.message }, { status: 500 })
73
+ } catch (err: unknown) {
74
+ const message = err instanceof Error ? err.message : String(err)
75
+ return NextResponse.json({ error: message }, { status: 500 })
49
76
  }
50
77
  }
51
78
 
@@ -0,0 +1,5 @@
1
+ import { PageLoader } from '@/components/ui/page-loader'
2
+
3
+ export default function Loading() {
4
+ return <PageLoader label="Loading home..." />
5
+ }
@@ -0,0 +1,5 @@
1
+ import { PageLoader } from '@/components/ui/page-loader'
2
+
3
+ export default function Loading() {
4
+ return <PageLoader label="Loading logs..." />
5
+ }
@@ -0,0 +1,5 @@
1
+ import { PageLoader } from '@/components/ui/page-loader'
2
+
3
+ export default function Loading() {
4
+ return <PageLoader label="Loading memory..." />
5
+ }
@@ -0,0 +1,5 @@
1
+ import { PageLoader } from '@/components/ui/page-loader'
2
+
3
+ export default function Loading() {
4
+ return <PageLoader label="Loading tasks..." />
5
+ }
@@ -60,27 +60,22 @@ export function AgentList({ inSidebar }: Props) {
60
60
  return ids
61
61
  }, [sessions])
62
62
 
63
- // Re-evaluate online status periodically (Date.now() can't be called in useMemo directly)
64
- const [now, setNow] = useState(() => Date.now())
65
- useEffect(() => {
66
- const id = setInterval(() => setNow(Date.now()), 60_000)
67
- return () => clearInterval(id)
68
- }, [])
69
-
70
- // Agents that are "online": heartbeat enabled + tools, or recently active (within 30min)
71
- const onlineAgentIds = useMemo(() => {
63
+ // Compute online agent IDs derives from agents and sessions data changes.
64
+ // The 30-minute threshold uses a callback to avoid calling Date.now() during render.
65
+ const computeOnlineIds = useCallback(() => {
72
66
  const ids = new Set<string>()
73
- const recentThreshold = now - 30 * 60 * 1000
67
+ const recentThreshold = Date.now() - 30 * 60 * 1000
74
68
  for (const a of Object.values(agents)) {
75
69
  if (a.disabled === true) continue
76
70
  if (a.heartbeatEnabled === true && getEnabledCapabilityIds(a).length > 0) { ids.add(a.id); continue }
77
- // Check if any session for this agent was active in the last 30 minutes
78
71
  for (const s of Object.values(sessions)) {
79
72
  if (s.agentId === a.id && (s.lastActiveAt ?? 0) > recentThreshold) { ids.add(a.id); break }
80
73
  }
81
74
  }
82
75
  return ids
83
- }, [agents, sessions, now])
76
+ }, [agents, sessions])
77
+ const [onlineAgentIds, setOnlineAgentIds] = useState(() => computeOnlineIds())
78
+ useEffect(() => { setOnlineAgentIds(computeOnlineIds()) }, [computeOnlineIds])
84
79
 
85
80
  // Approval counts per agent
86
81
  const approvalsByAgent = useMemo(() => {
@@ -311,12 +311,12 @@ export function ChatArea() {
311
311
  useWs(
312
312
  sessionId ? `messages:${sessionId}` : '',
313
313
  refreshMessages,
314
- shouldPollMessages ? 2000 : undefined,
314
+ shouldPollMessages ? 10_000 : undefined,
315
315
  )
316
316
  useWs(
317
317
  sessionId ? 'runs' : '',
318
318
  refreshQueue,
319
- sessionId && (isServerActive || queuedCount > 0) ? 2000 : undefined,
319
+ sessionId && (isServerActive || queuedCount > 0) ? 10_000 : undefined,
320
320
  )
321
321
 
322
322
  // Listen for stream-end signal from the server — clears streaming state
@@ -363,7 +363,7 @@ export function ChatArea() {
363
363
  useWs(
364
364
  hasBrowserTool && sessionId ? `browser:${sessionId}` : '',
365
365
  checkBrowserStatus,
366
- hasBrowserTool ? 5000 : undefined,
366
+ hasBrowserTool ? 30_000 : undefined,
367
367
  )
368
368
 
369
369
  const handleStopBrowser = useCallback(async () => {
@@ -64,13 +64,24 @@ export function ChatList({ inSidebar, onSelect }: Props) {
64
64
  }))
65
65
  }, [sessions, currentUser, showLocalPlatformSessions])
66
66
 
67
+ // Pre-index connectors by agentId for O(1) lookup instead of O(connectors) per session
68
+ const connectorByAgentId = useMemo(() => {
69
+ const index = new Map<string, typeof connectors[string]>()
70
+ for (const item of Object.values(connectors)) {
71
+ if (item.chatroomId == null && item.agentId && item.isEnabled !== false) {
72
+ index.set(item.agentId, item)
73
+ }
74
+ }
75
+ return index
76
+ }, [connectors])
77
+
67
78
  const filtered = useMemo(() => {
68
79
  return allUserSessions
69
80
  .filter((s) => {
70
81
  const unreadCount = (getSessionLastAssistantAt(s) || 0) > (lastReadTimestamps[s.id] || 0) ? 1 : 0
71
82
  if (search) {
72
83
  const agent = s.agentId ? agents[s.agentId] : null
73
- const connector = Object.values(connectors).find((item) => item.chatroomId == null && item.agentId === s.agentId && item.isEnabled !== false)
84
+ const connector = s.agentId ? connectorByAgentId.get(s.agentId) : undefined
74
85
  const lastMessage = getSessionLastMessage(s)
75
86
  const haystack = [
76
87
  s.name,
@@ -102,7 +113,7 @@ export function ChatList({ inSidebar, onSelect }: Props) {
102
113
  if (sortMode === 'messages') return getSessionMessageCount(b) - getSessionMessageCount(a)
103
114
  return (b.lastActiveAt || 0) - (a.lastActiveAt || 0)
104
115
  })
105
- }, [agents, allUserSessions, connectors, lastReadTimestamps, search, sortMode, typeFilter])
116
+ }, [agents, allUserSessions, connectorByAgentId, lastReadTimestamps, search, sortMode, typeFilter])
106
117
 
107
118
  const handleSelect = async (id: string) => {
108
119
  const agentId = sessions[id]?.agentId
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useState } from 'react'
3
+ import { useState, useEffect } from 'react'
4
4
  import { usePathname } from 'next/navigation'
5
5
  import { useAppStore } from '@/stores/use-app-store'
6
6
  import { Avatar } from '@/components/shared/avatar'
@@ -8,6 +8,7 @@ import { AgentAvatar } from '@/components/agents/agent-avatar'
8
8
  import { DaemonIndicator } from '@/components/layout/daemon-indicator'
9
9
  import { NotificationCenter } from '@/components/shared/notification-center'
10
10
  import { NavItem, RailTooltip } from '@/components/layout/nav-item'
11
+ import { useWs } from '@/hooks/use-ws'
11
12
  import { FULL_WIDTH_VIEWS } from '@/lib/app/view-constants'
12
13
  import { pathToView, useNavigate } from '@/lib/app/navigation'
13
14
  import { safeStorageGet, safeStorageSet } from '@/lib/app/safe-storage'
@@ -29,16 +30,20 @@ export function SidebarRail({
29
30
  const navigateTo = useNavigate()
30
31
  const currentUser = useAppStore((s) => s.currentUser)
31
32
  const appSettings = useAppStore((s) => s.appSettings)
32
- const agents = useAppStore((s) => s.agents)
33
+ const defaultAgent = useAppStore((s) => {
34
+ const defaultId = s.appSettings.defaultAgentId
35
+ if (defaultId && s.agents[defaultId]) return s.agents[defaultId]
36
+ const first = Object.values(s.agents)[0]
37
+ return first || null
38
+ })
33
39
  const currentAgentId = useAppStore((s) => s.currentAgentId)
34
40
  const sidebarOpen = useAppStore((s) => s.sidebarOpen)
35
41
  const setSidebarOpen = useAppStore((s) => s.setSidebarOpen)
42
+ const skillDraftCount = useAppStore((s) => s.skillDraftCount)
43
+ const loadSkillDraftCount = useAppStore((s) => s.loadSkillDraftCount)
36
44
 
37
45
  const activeView = pathToView(pathname) ?? 'home'
38
46
 
39
- const defaultAgent = appSettings.defaultAgentId && agents[appSettings.defaultAgentId]
40
- ? agents[appSettings.defaultAgentId]
41
- : Object.values(agents)[0] || null
42
47
  const defaultAgentId = defaultAgent?.id || null
43
48
  const isDefaultChat = activeView === 'agents' && currentAgentId === defaultAgentId
44
49
 
@@ -49,6 +54,9 @@ export function SidebarRail({
49
54
  // Mobile always forces expanded
50
55
  const railExpanded = mobile || railExpandedStored
51
56
 
57
+ useEffect(() => { void loadSkillDraftCount() }, [loadSkillDraftCount])
58
+ useWs('skills', loadSkillDraftCount)
59
+
52
60
  const toggleRail = () => {
53
61
  if (mobile) return
54
62
  const next = !railExpandedStored
@@ -295,7 +303,7 @@ export function SidebarRail({
295
303
  <circle cx="12" cy="12" r="10" /><line x1="2" y1="12" x2="22" y2="12" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
296
304
  </svg>
297
305
  </NavItem>
298
- <NavItem view="skills" label="Skills" expanded={railExpanded} isActive={isNavActive('skills')} onClick={() => handleNavClick('skills')}>
306
+ <NavItem view="skills" label="Skills" badge={skillDraftCount} expanded={railExpanded} isActive={isNavActive('skills')} onClick={() => handleNavClick('skills')}>
299
307
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
300
308
  <path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" /><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
301
309
  </svg>
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useEffect, useRef, useState } from 'react'
3
+ import { useEffect, useRef, useState, useCallback } from 'react'
4
4
  import { api } from '@/lib/app/api-client'
5
5
  import { useAppStore } from '@/stores/use-app-store'
6
6
 
@@ -21,13 +21,19 @@ interface Link {
21
21
  type: string
22
22
  }
23
23
 
24
+ /** Kinetic energy threshold — stop simulation when total energy drops below this. */
25
+ const SETTLE_THRESHOLD = 0.5
26
+
24
27
  export function MemoryGraphView() {
25
- const [data, setData] = useState<{ nodes: Node[]; links: Link[] }>({ nodes: [], links: [] })
28
+ const [initialData, setInitialData] = useState<{ nodes: Node[]; links: Link[] } | null>(null)
26
29
  const [loading, setLoading] = useState(true)
27
30
  const [hoveredNode, setHoveredNode] = useState<string | null>(null)
28
31
  const containerRef = useRef<HTMLDivElement>(null)
32
+ const svgRef = useRef<SVGSVGElement>(null)
29
33
  const requestRef = useRef<number>(null)
30
-
34
+ const nodesRef = useRef<Node[]>([])
35
+ const linksRef = useRef<Link[]>([])
36
+
31
37
  const selectedMemoryId = useAppStore((s) => s.selectedMemoryId)
32
38
  const setSelectedMemoryId = useAppStore((s) => s.setSelectedMemoryId)
33
39
  const memoryAgentFilter = useAppStore((s) => s.memoryAgentFilter)
@@ -38,7 +44,7 @@ export function MemoryGraphView() {
38
44
  try {
39
45
  const url = `/memory/graph${memoryAgentFilter ? `?agentId=${memoryAgentFilter}` : ''}`
40
46
  const res = await api<{ nodes: Node[]; links: Link[] }>('GET', url)
41
-
47
+
42
48
  // Initialize positions
43
49
  const nodes = res.nodes.map(n => ({
44
50
  ...n,
@@ -47,8 +53,10 @@ export function MemoryGraphView() {
47
53
  vx: 0,
48
54
  vy: 0
49
55
  }))
50
-
51
- setData({ nodes, links: res.links })
56
+
57
+ nodesRef.current = nodes
58
+ linksRef.current = res.links
59
+ setInitialData({ nodes, links: res.links })
52
60
  } catch (err) {
53
61
  console.error('Failed to load memory graph', err)
54
62
  } finally {
@@ -58,75 +66,112 @@ export function MemoryGraphView() {
58
66
  load()
59
67
  }, [memoryAgentFilter])
60
68
 
61
- // Simple Force-Directed Simulation
69
+ // Write positions directly to SVG DOM — no React state updates per frame
70
+ const updateDOM = useCallback(() => {
71
+ const svg = svgRef.current
72
+ if (!svg) return
73
+ const nodes = nodesRef.current
74
+
75
+ // Update link positions
76
+ const lineElements = svg.querySelectorAll<SVGLineElement>('[data-link]')
77
+ lineElements.forEach((el) => {
78
+ const srcId = el.getAttribute('data-src')
79
+ const tgtId = el.getAttribute('data-tgt')
80
+ const s = nodes.find(n => n.id === srcId)
81
+ const t = nodes.find(n => n.id === tgtId)
82
+ if (s && t) {
83
+ el.setAttribute('x1', String(s.x))
84
+ el.setAttribute('y1', String(s.y))
85
+ el.setAttribute('x2', String(t.x))
86
+ el.setAttribute('y2', String(t.y))
87
+ }
88
+ })
89
+
90
+ // Update node positions
91
+ const gElements = svg.querySelectorAll<SVGGElement>('[data-node-id]')
92
+ gElements.forEach((el) => {
93
+ const id = el.getAttribute('data-node-id')
94
+ const node = nodes.find(n => n.id === id)
95
+ if (node) {
96
+ el.setAttribute('transform', `translate(${node.x},${node.y})`)
97
+ }
98
+ })
99
+ }, [])
100
+
101
+ // Force-directed simulation running in refs, writing to DOM imperatively
62
102
  useEffect(() => {
63
- if (data.nodes.length === 0) return
103
+ const nodes = nodesRef.current
104
+ const links = linksRef.current
105
+ if (nodes.length === 0) return
64
106
 
65
107
  const animate = () => {
66
- setData(prev => {
67
- const nodes = [...prev.nodes]
68
- const links = prev.links
69
-
70
- // 1. Repulsion between all nodes
71
- for (let i = 0; i < nodes.length; i++) {
72
- for (let j = i + 1; j < nodes.length; j++) {
73
- const dx = nodes[i].x - nodes[j].x
74
- const dy = nodes[i].y - nodes[j].y
75
- const distSq = dx * dx + dy * dy + 0.1
76
- const force = 400 / distSq
77
- const fx = dx * force
78
- const fy = dy * force
79
- nodes[i].vx += fx
80
- nodes[i].vy += fy
81
- nodes[j].vx -= fx
82
- nodes[j].vy -= fy
83
- }
84
- }
108
+ let totalEnergy = 0
85
109
 
86
- // 2. Attraction along links
87
- for (const link of links) {
88
- const source = nodes.find(n => n.id === link.source)
89
- const target = nodes.find(n => n.id === link.target)
90
- if (source && target) {
91
- const dx = target.x - source.x
92
- const dy = target.y - source.y
93
- const dist = Math.sqrt(dx * dx + dy * dy) + 0.1
94
- const force = (dist - 100) * 0.02
95
- const fx = (dx / dist) * force
96
- const fy = (dy / dist) * force
97
- source.vx += fx
98
- source.vy += fy
99
- target.vx -= fx
100
- target.vy -= fy
101
- }
110
+ // 1. Repulsion between all nodes
111
+ for (let i = 0; i < nodes.length; i++) {
112
+ for (let j = i + 1; j < nodes.length; j++) {
113
+ const dx = nodes[i].x - nodes[j].x
114
+ const dy = nodes[i].y - nodes[j].y
115
+ const distSq = dx * dx + dy * dy + 0.1
116
+ const force = 400 / distSq
117
+ const fx = dx * force
118
+ const fy = dy * force
119
+ nodes[i].vx += fx
120
+ nodes[i].vy += fy
121
+ nodes[j].vx -= fx
122
+ nodes[j].vy -= fy
102
123
  }
124
+ }
103
125
 
104
- // 3. Centering force
105
- const cx = 400
106
- const cy = 300
107
- for (const node of nodes) {
108
- node.vx += (cx - node.x) * 0.01
109
- node.vy += (cy - node.y) * 0.01
126
+ // 2. Attraction along links
127
+ for (const link of links) {
128
+ const source = nodes.find(n => n.id === link.source)
129
+ const target = nodes.find(n => n.id === link.target)
130
+ if (source && target) {
131
+ const dx = target.x - source.x
132
+ const dy = target.y - source.y
133
+ const dist = Math.sqrt(dx * dx + dy * dy) + 0.1
134
+ const force = (dist - 100) * 0.02
135
+ const fx = (dx / dist) * force
136
+ const fy = (dy / dist) * force
137
+ source.vx += fx
138
+ source.vy += fy
139
+ target.vx -= fx
140
+ target.vy -= fy
110
141
  }
142
+ }
111
143
 
112
- // 4. Update positions with damping
113
- for (const node of nodes) {
114
- node.x += node.vx
115
- node.y += node.vy
116
- node.vx *= 0.8
117
- node.vy *= 0.8
118
- }
144
+ // 3. Centering force
145
+ const cx = 400
146
+ const cy = 300
147
+ for (const node of nodes) {
148
+ node.vx += (cx - node.x) * 0.01
149
+ node.vy += (cy - node.y) * 0.01
150
+ }
151
+
152
+ // 4. Update positions with damping
153
+ for (const node of nodes) {
154
+ node.x += node.vx
155
+ node.y += node.vy
156
+ node.vx *= 0.8
157
+ node.vy *= 0.8
158
+ totalEnergy += node.vx * node.vx + node.vy * node.vy
159
+ }
160
+
161
+ // Write positions to DOM imperatively
162
+ updateDOM()
119
163
 
120
- return { nodes, links }
121
- })
122
- requestRef.current = requestAnimationFrame(animate)
164
+ // Stop when settled
165
+ if (totalEnergy > SETTLE_THRESHOLD) {
166
+ requestRef.current = requestAnimationFrame(animate)
167
+ }
123
168
  }
124
169
 
125
170
  requestRef.current = requestAnimationFrame(animate)
126
171
  return () => {
127
172
  if (requestRef.current) cancelAnimationFrame(requestRef.current)
128
173
  }
129
- }, [data.nodes.length])
174
+ }, [initialData, updateDOM])
130
175
 
131
176
  if (loading) {
132
177
  return (
@@ -136,17 +181,23 @@ export function MemoryGraphView() {
136
181
  )
137
182
  }
138
183
 
184
+ const nodes = nodesRef.current
185
+ const links = linksRef.current
186
+
139
187
  return (
140
188
  <div ref={containerRef} className="flex-1 relative overflow-hidden bg-black/20 rounded-[16px] border border-white/[0.06]">
141
- <svg width="100%" height="100%" viewBox="0 0 800 600" preserveAspectRatio="xMidYMid meet">
189
+ <svg ref={svgRef} width="100%" height="100%" viewBox="0 0 800 600" preserveAspectRatio="xMidYMid meet">
142
190
  {/* Links */}
143
- {data.links.map((link, i) => {
144
- const s = data.nodes.find(n => n.id === link.source)
145
- const t = data.nodes.find(n => n.id === link.target)
191
+ {links.map((link, i) => {
192
+ const s = nodes.find(n => n.id === link.source)
193
+ const t = nodes.find(n => n.id === link.target)
146
194
  if (!s || !t) return null
147
195
  return (
148
196
  <line
149
197
  key={i}
198
+ data-link=""
199
+ data-src={link.source}
200
+ data-tgt={link.target}
150
201
  x1={s.x} y1={s.y}
151
202
  x2={t.x} y2={t.y}
152
203
  stroke="white"
@@ -157,9 +208,10 @@ export function MemoryGraphView() {
157
208
  })}
158
209
 
159
210
  {/* Nodes */}
160
- {data.nodes.map(node => (
161
- <g
162
- key={node.id}
211
+ {nodes.map(node => (
212
+ <g
213
+ key={node.id}
214
+ data-node-id={node.id}
163
215
  transform={`translate(${node.x},${node.y})`}
164
216
  onMouseEnter={() => setHoveredNode(node.id)}
165
217
  onMouseLeave={() => setHoveredNode(null)}
@@ -186,7 +238,7 @@ export function MemoryGraphView() {
186
238
  </g>
187
239
  ))}
188
240
  </svg>
189
-
241
+
190
242
  {/* Legend */}
191
243
  <div className="absolute bottom-4 left-4 p-3 bg-surface/80 backdrop-blur rounded-[12px] border border-white/[0.06] flex flex-col gap-2">
192
244
  <div className="flex items-center gap-2">