Compare commits

...
Author SHA1 Message Date
luccast e0b5ddfec5 fix(ci): use .output dir for build artifact 2026-01-30 20:46:54 -05:00
luccast 154c93131e chore: regenerate lock file, bump to 1.0.7 2026-01-30 20:44:40 -05:00
luccast 50114100a2 infra: update CI to Node 24 for npm 11 lock file compat 2026-01-30 20:41:17 -05:00
Luciano Castillo 2ba3ef6d47 feat(monitor): add follow mode to track new nodes (#23)
* feat(monitor): implement follow mode in ActionGraph for new nodes

- Added a follow mode feature that automatically pans the viewport to new nodes in the ActionGraph.
- Introduced a button to toggle follow mode, enhancing user experience by allowing users to track new nodes easily.
- Updated the ActionGraphInner component to manage viewport changes and node tracking effectively.

* feat(graph-layout): implement caching for spawn Y positions to prevent jitter

- Introduced a caching mechanism for spawn Y positions of child sessions to maintain stability during parent action accumulation.
- Updated the layoutGraph function to utilize cached values, reducing recalculation and improving layout consistency.
- Added cleanup logic to remove entries from the cache for sessions that no longer exist, ensuring efficient memory usage.

* style(ActionGraph): enhance follow mode button appearance

- Updated the styling of the follow mode button in the ActionGraphInner component to include a backdrop blur effect, improving visual feedback for users when the mode is active.
2026-01-30 16:52:29 -05:00
5 changed files with 330 additions and 248 deletions
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
cache: npm
- name: Install dependencies
@@ -33,7 +33,7 @@ jobs:
- name: Create build artifact
run: |
tar -czvf crabwalk-${{ github.ref_name }}.tar.gz dist
tar -czvf crabwalk-${{ github.ref_name }}.tar.gz .output
- name: Upload build to release
uses: softprops/action-gh-release@v1
+259 -240
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "crabwalk",
"version": "1.0.5",
"version": "1.0.7",
"private": true,
"type": "module",
"scripts": {
+44 -3
View File
@@ -6,6 +6,8 @@ import {
MiniMap,
useNodesState,
useEdgesState,
useReactFlow,
useOnViewportChange,
type Node,
type Edge,
type NodeTypes,
@@ -13,7 +15,7 @@ import {
MarkerType,
ReactFlowProvider,
} from '@xyflow/react'
import { LayoutGrid, ArrowRightLeft, ArrowUpDown } from 'lucide-react'
import { LayoutGrid, ArrowRightLeft, ArrowUpDown, Crosshair } from 'lucide-react'
import '@xyflow/react/dist/style.css'
import { SessionNode } from './SessionNode'
import { ActionNode } from './ActionNode'
@@ -100,6 +102,22 @@ function ActionGraphInner({
// Layout direction: LR = horizontal (sessions spawn right), TB = vertical (sessions stack down)
const [layoutDirection, setLayoutDirection] = useState<'LR' | 'TB'>('LR')
// Follow mode: auto-pan to new nodes
const [followMode, setFollowMode] = useState(false)
const isAnimatingRef = useRef(false)
// Get ReactFlow instance for viewport control
const { setCenter } = useReactFlow()
// Detect manual panning and auto-disable follow mode
useOnViewportChange({
onEnd: useCallback(() => {
if (followMode && !isAnimatingRef.current) {
setFollowMode(false)
}
}, [followMode]),
})
// Filter actions for selected session, or show all if none selected
const visibleActions = useMemo(() => {
if (!selectedSession) return actions.slice(-50)
@@ -457,6 +475,9 @@ function ActionGraphInner({
const prevPositions = nodePositionsRef.current
const crab = crabRef.current
// Track the latest new node for follow mode
let latestNewNode: { x: number; y: number } | null = null
// Check for new nodes
for (const node of layoutedNodes) {
if (!node.id.includes('crab')) {
@@ -470,7 +491,7 @@ function ActionGraphInner({
crab.target = { ...nodeCenter, nodeId: node.id }
crab.state = 'chasing'
if (timeoutRef.current) clearTimeout(timeoutRef.current)
break
latestNewNode = nodeCenter
}
// Existing node moved - if we were tracking it or idle, chase it
@@ -489,8 +510,17 @@ function ActionGraphInner({
}
}
// Follow mode: pan to the latest new node
if (followMode && latestNewNode) {
isAnimatingRef.current = true
setCenter(latestNewNode.x, latestNewNode.y, { zoom: 0.85, duration: 500 })
setTimeout(() => {
isAnimatingRef.current = false
}, 550)
}
prevNodeIdsRef.current = currentIds
}, [layoutedNodes])
}, [layoutedNodes, followMode, setCenter])
// Main animation loop - step-based crab movement at 10fps timing
useEffect(() => {
@@ -754,6 +784,17 @@ function ActionGraphInner({
className="bg-shell-900! border-shell-700! shadow-lg! [&>button]:bg-shell-800! [&>button]:border-shell-700! [&>button]:text-gray-300! [&>button:hover]:bg-shell-700! [&>button>svg]:fill-gray-300!"
/>
<div className="absolute top-2 right-2 z-10 flex gap-1.5">
<button
onClick={() => setFollowMode((prev) => !prev)}
title={followMode ? 'Following new nodes (click to disable)' : 'Follow new nodes'}
className={`p-1.5 rounded border shadow-lg cursor-pointer transition-colors ${
followMode
? 'bg-neon-cyan/20 border-neon-cyan text-neon-cyan backdrop-blur-lg'
: 'bg-shell-800 border-shell-700 text-gray-300 hover:bg-shell-700'
}`}
>
<Crosshair className="w-4 h-4" />
</button>
<button
onClick={() => {
setLayoutDirection((d) => (d === 'LR' ? 'TB' : 'LR'))
+24 -2
View File
@@ -35,6 +35,10 @@ const ROOT_START_Y = 200 // Vertical offset from crab to first root session
const MIN_SESSION_GAP = 120 // Minimum vertical gap between sessions in same column
const ROOT_HORIZONTAL_GAP = 0 // Gap between root sessions in horizontal mode
// Cache spawn Y positions so they don't change as parent actions accumulate
// Key: session key, Value: calculated spawn Y offset
const spawnYCache = new Map<string, number>()
interface SessionColumn {
sessionKey: string
columnIndex: number
@@ -215,6 +219,7 @@ export function layoutGraph(
// Calculate spawn Y positions for child sessions
// When a session is spawned, find the Y position of the parent at that time
// Cache these values so they don't jitter as parent actions accumulate
for (const session of sessions) {
if (!session.spawnedBy) continue
@@ -222,6 +227,13 @@ export function layoutGraph(
const childCol = sessionColumns.get(session.key)
if (!parentCol || !childCol) continue
// Use cached spawn Y if available (prevents jitter from recalculation)
const cachedSpawnY = spawnYCache.get(session.key)
if (cachedSpawnY !== undefined) {
childCol.spawnY = cachedSpawnY
continue
}
// Find the approximate position in parent where spawn happened
// Use the child's creation time (approximated by first action time or session activity)
const childActions = actionsBySession.get(session.key) ?? []
@@ -239,8 +251,10 @@ export function layoutGraph(
}
}
// Calculate Y based on parent's item count
childCol.spawnY = parentItemsBeforeSpawn * (NODE_DIMENSIONS.action.height + ROW_GAP) + SPAWN_OFFSET
// Calculate Y based on parent's item count and cache it
const calculatedSpawnY = parentItemsBeforeSpawn * (NODE_DIMENSIONS.action.height + ROW_GAP) + SPAWN_OFFSET
spawnYCache.set(session.key, calculatedSpawnY)
childCol.spawnY = calculatedSpawnY
}
// Position all nodes
@@ -362,6 +376,14 @@ export function layoutGraph(
}
}
// Clean up spawn Y cache for sessions that no longer exist
const currentSessionKeys = new Set(sessions.map(s => s.key))
for (const key of spawnYCache.keys()) {
if (!currentSessionKeys.has(key)) {
spawnYCache.delete(key)
}
}
return { nodes: positionedNodes, edges }
}