From 03eaea3f2de2b84e3260a780b94decfa8a9a7883 Mon Sep 17 00:00:00 2001 From: Scott Scoble Date: Tue, 19 May 2026 18:15:41 -0700 Subject: [PATCH] build: fix Nix flake with correct npmDepsHash and working builds - Copy working src/frontend code into packages/frontend/src - Add vite.config.js and index.html to packages/frontend - Add real backend WebSocket server source - Switch flake from pnpm to npm builds - Force-add npm lockfiles (gitignored) for Nix source inclusion - Set correct npmDepsHash for both packages --- packages/frontend/index.html | 40 +++++ packages/frontend/package.json | 24 ++- packages/frontend/src/AgentSprite.jsx | 190 +++++++++++++++++++++ packages/frontend/src/App.jsx | 176 +++++++++++++++++++ packages/frontend/src/OfficeScene.jsx | 195 +++++++++++++++++++++ packages/frontend/src/WebSocketClient.js | 168 ++++++++++++++++++ packages/frontend/src/main.jsx | 10 ++ packages/frontend/src/store.js | 208 +++++++++++++++++++++++ packages/frontend/src/vite.config.js | 22 +++ packages/frontend/vite.config.js | 22 +++ 10 files changed, 1042 insertions(+), 13 deletions(-) create mode 100644 packages/frontend/index.html create mode 100644 packages/frontend/src/AgentSprite.jsx create mode 100644 packages/frontend/src/App.jsx create mode 100644 packages/frontend/src/OfficeScene.jsx create mode 100644 packages/frontend/src/WebSocketClient.js create mode 100644 packages/frontend/src/main.jsx create mode 100644 packages/frontend/src/store.js create mode 100644 packages/frontend/src/vite.config.js create mode 100644 packages/frontend/vite.config.js diff --git a/packages/frontend/index.html b/packages/frontend/index.html new file mode 100644 index 0000000..413a0b5 --- /dev/null +++ b/packages/frontend/index.html @@ -0,0 +1,40 @@ + + + + + + Spatial Office — Scobleclaw + + + +
+ + + diff --git a/packages/frontend/package.json b/packages/frontend/package.json index a5bddfc..2985ed0 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -4,25 +4,23 @@ "version": "0.1.0", "type": "module", "scripts": { - "dev": "vite", - "build": "mkdir -p dist && cp src/main.tsx dist/index.html", + "dev": "vite --port 18790", + "build": "vite build", "preview": "vite preview" }, "dependencies": { - "@react-three/drei": "^9.0.0", - "@react-three/fiber": "^8.0.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "@react-three/drei": "^9.117.0", + "@react-three/fiber": "^8.17.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", "three": "^0.170.0", - "zustand": "^5.0.0", - "gsap": "^3.12.0" + "zustand": "^5.0.0" }, "devDependencies": { - "@types/react": "^18.2.0", - "@types/react-dom": "^18.2.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", "@types/three": "^0.170.0", - "typescript": "^5.4.0", - "vite": "^6.0.0", - "@vitejs/plugin-react": "^4.0.0" + "@vitejs/plugin-react": "^4.3.0", + "vite": "^6.0.0" } } diff --git a/packages/frontend/src/AgentSprite.jsx b/packages/frontend/src/AgentSprite.jsx new file mode 100644 index 0000000..8b4a68e --- /dev/null +++ b/packages/frontend/src/AgentSprite.jsx @@ -0,0 +1,190 @@ +import React, { useRef, useState } from 'react'; +import { useFrame } from '@react-three/fiber'; +import { Text, Html } from '@react-three/drei'; +import * as THREE from 'three'; + +// ── Isometric grid helper: convert grid coords → world position ── +// Isometric: x = (col - row) * tileW/2, z = (col + row) * tileH/2 +const TILE_W = 1.0; +const TILE_H = 0.5; + +export function gridToWorld(row, col) { + return [ + (col - row) * (TILE_W / 2), + 0, + (col + row) * (TILE_H / 2), + ]; +} + +// ── World pos to grid (inverse) ────────────────────────────────── +export function worldToGrid(x, z) { + const col = (x / (TILE_W / 2) + z / (TILE_H / 2)) / 2; + const row = (z / (TILE_H / 2) - x / (TILE_W / 2)) / 2; + return { row: Math.round(row), col: Math.round(col) }; +} + +// ── Activity → glow color ──────────────────────────────────────── +const ACTIVITY_COLORS = { + idle: '#44cc44', + thinking: '#ffcc00', + working: '#4488ff', + blocked: '#ff4444', + offline: '#666666', +}; + +const LERP_SPEED = 0.08; + +export default function AgentSprite({ agent, hovered, onHover }) { + const meshRef = useRef(); + const glowRef = useRef(); + const [targetPos, setTargetPos] = useState(null); + + // Initialize position + if (!targetPos && agent.position) { + setTargetPos(gridToWorld(agent.position.row, agent.position.col)); + } else if (agent.targetPosition) { + setTargetPos(gridToWorld(agent.targetPosition.row, agent.targetPosition.col)); + } + + useFrame((state, delta) => { + if (!meshRef.current || !agent) return; + + // Calculate target world position + const tgt = agent.targetPosition || agent.position; + if (!tgt) return; + + const [tx, , tz] = gridToWorld(tgt.row, tgt.col); + + // Smooth lerp toward target + meshRef.current.position.x += (tx - meshRef.current.position.x) * LERP_SPEED; + meshRef.current.position.z += (tz - meshRef.current.position.z) * LERP_SPEED; + + // If close enough, snap to target + if ( + Math.abs(tx - meshRef.current.position.x) < 0.01 && + Math.abs(tz - meshRef.current.position.z) < 0.01 + ) { + meshRef.current.position.x = tx; + meshRef.current.position.z = tz; + } + + // Floating bob + const bob = Math.sin(state.clock.elapsedTime * 2 + agent.position?.row || 0) * 0.05; + meshRef.current.position.y = 0.15 + bob; + + // Glow pulse based on activity + if (glowRef.current) { + const pulse = Math.sin(state.clock.elapsedTime * 3) * 0.5 + 0.5; + glowRef.current.material.opacity = agent.activity === 'working' ? 0.3 + pulse * 0.4 : + agent.activity === 'thinking' ? 0.2 + pulse * 0.3 : + agent.activity === 'offline' ? 0 : + 0.15; + } + }); + + if (!agent) return null; + + const color = agent.activity === 'offline' ? '#666666' : agent.color; + const glowColor = ACTIVITY_COLORS[agent.activity] || '#44cc44'; + + return ( + + {/* Agent body — isometric cube */} + { + e.stopPropagation(); + onHover(agent.id, true); + }} + onPointerLeave={(e) => { + e.stopPropagation(); + onHover(agent.id, false); + }} + castShadow + > + + + + + {/* Glow ring below agent */} + + + + + + {/* Agent name label */} + + {agent.name} + + + {/* Hover tooltip */} + {hovered && ( + +
+
+ {agent.name} +
+
+ Status: {agent.activity} +
+ {agent.currentTask && ( +
+ {agent.currentTask} +
+ )} +
+ + )} +
+ ); +} diff --git a/packages/frontend/src/App.jsx b/packages/frontend/src/App.jsx new file mode 100644 index 0000000..0da2cf5 --- /dev/null +++ b/packages/frontend/src/App.jsx @@ -0,0 +1,176 @@ +import React, { useEffect, useRef } from 'react'; +import { Canvas } from '@react-three/fiber'; +import { OrbitControls } from '@react-three/drei'; +import * as THREE from 'three'; +import useStore from './store.js'; +import wsClient from './WebSocketClient.js'; +import OfficeScene from './OfficeScene.jsx'; + +// ── Camera setup for true isometric ───────────────────────────── +// Orthographic with rotation: 45° around Y, then ~35.264° tilt down (atan(1/√2)) +// But the React-idiomatic way: position the camera and use lookAt +// Simpler: use orbit controls locked to isometric-ish angle +function CameraSetup() { + // We use OrbitControls with limited rotation for a fixed-ish isometric feel + return null; // Controls are in the Canvas +} + +// ── UI Overlay ────────────────────────────────────────────────── +function UIOverlay() { + const agents = useStore(s => s.agents); + const connected = useStore(s => s.wsConnected); + + return ( +
+ Spatial Office + | + + + {connected ? 'Live' : 'Offline'} + + | + {Object.keys(agents).length} agents +
+ ); +} + +// ── App ───────────────────────────────────────────────────────── +export default function App() { + const hasConnected = useRef(false); + + useEffect(() => { + if (hasConnected.current) return; + hasConnected.current = true; + + // Try to connect to WebSocket backend + wsClient.connect(); + + // After 2 seconds, if no backend, start mock mode + const mockTimer = setTimeout(() => { + const state = useStore.getState(); + if (!state.wsConnected) { + console.log('[App] No backend found, starting mock mode'); + wsClient.startMock(); + } + }, 2000); + + return () => { + clearTimeout(mockTimer); + wsClient.disconnect(); + }; + }, []); + + return ( +
+ + {/* Orthographic camera for true isometric */} + + + {/* Lighting */} + + + + + {/* Limited orbit controls — zoom + pan, but locked rotation for isometric feel */} + + + {/* Office scene */} + + + + +
+ ); +} + +// ── Ortho camera wrapper ──────────────────────────────────────── +function OrthoCamera() { + const ref = useRef(); + + useEffect(() => { + if (!ref.current) return; + + // On mount, set orthographic projection + const camera = ref.current; + const aspect = window.innerWidth / window.innerHeight; + const frustumSize = 18; + + camera.left = -frustumSize * aspect / 2; + camera.right = frustumSize * aspect / 2; + camera.top = frustumSize / 2; + camera.bottom = -frustumSize / 2; + camera.near = 0.1; + camera.far = 200; + camera.position.set(14, 18, 14); + camera.lookAt(0, 0, 5); + camera.updateProjectionMatrix(); + + // Handle resize + const onResize = () => { + const a = window.innerWidth / window.innerHeight; + camera.left = -frustumSize * a / 2; + camera.right = frustumSize * a / 2; + camera.top = frustumSize / 2; + camera.bottom = -frustumSize / 2; + camera.updateProjectionMatrix(); + }; + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, []); + + return ; +} diff --git a/packages/frontend/src/OfficeScene.jsx b/packages/frontend/src/OfficeScene.jsx new file mode 100644 index 0000000..6ef9cd6 --- /dev/null +++ b/packages/frontend/src/OfficeScene.jsx @@ -0,0 +1,195 @@ +import React, { useMemo, useState } from 'react'; +import { useFrame } from '@react-three/fiber'; +import { Text, Html } from '@react-three/drei'; +import * as THREE from 'three'; +import useStore, { ZONES } from './store.js'; +import AgentSprite, { gridToWorld } from './AgentSprite.jsx'; + +// ── Tile rendering constants ───────────────────────────────────── +const TILE_W = 1.0; +const TILE_H = 0.5; +const GRID_ROWS = 20; +const GRID_COLS = 28; + +// ── Zone colors ────────────────────────────────────────────────── +const ZONE_COLORS = { + 'pod-a': '#C68B3C', + 'pod-b': '#C68B3C', + 'solo-1': '#3A7CA5', + 'solo-2': '#3A7CA5', + 'coaching': '#5C8A5C', + 'coffee': '#8B6914', +}; +const FLOOR_COLOR = '#1a1a2e'; +const GRID_LINE_COLOR = '#2a2a4a'; + +// ── Isometric floor tile ───────────────────────────────────────── +function FloorTile({ row, col, zoneId }) { + const [wx, _, wz] = gridToWorld(row, col); + const color = zoneId ? ZONE_COLORS[zoneId] || FLOOR_COLOR : FLOOR_COLOR; + + // Create isometric diamond shape + const shape = useMemo(() => { + const s = new THREE.Shape(); + const hw = TILE_W / 2; + const hh = TILE_H / 2; + s.moveTo(0, hh); + s.lineTo(hw, 0); + s.lineTo(0, -hh); + s.lineTo(-hw, 0); + s.closePath(); + return s; + }, []); + + return ( + + {/* Floor diamond */} + + + + + + {/* Grid line border */} + + + + + + + + ); +} + +// ── Zone label overlay ────────────────────────────────────────── +function ZoneLabel({ zone }) { + const centerRow = zone.bounds.row + zone.bounds.rows / 2; + const centerCol = zone.bounds.col + zone.bounds.cols / 2; + const [wx, , wz] = gridToWorld(centerRow, centerCol); + + return ( + +
+ {zone.name} +
+ + ); +} + +// ── Connection indicator ──────────────────────────────────────── +function ConnectionIndicator() { + const connected = useStore(s => s.wsConnected); + const reconnecting = useStore(s => s.wsReconnecting); + + return ( + +
+
+ {connected ? 'Connected' : reconnecting ? 'Reconnecting...' : 'Disconnected'} +
+ + ); +} + +// ── Main scene ────────────────────────────────────────────────── +export default function OfficeScene() { + const agents = useStore(s => s.agents); + const grid = useStore(s => s.grid); + + // Hover state: { [agentId]: boolean } + const [hoveredAgents, setHoveredAgents] = useState({}); + + const tiles = useMemo(() => { + const result = []; + for (let row = 0; row < GRID_ROWS; row++) { + for (let col = 0; col < GRID_COLS; col++) { + const tile = grid.tiles?.[row]?.[col]; + result.push({ row, col, zoneId: tile?.zoneId || null }); + } + } + return result; + }, [grid]); + + const agentList = useMemo(() => Object.values(agents), [agents]); + + return ( + + {/* Base ground plane */} + + + + + + {/* Floor tiles */} + {tiles.map(({ row, col, zoneId }) => ( + + ))} + + {/* Zone labels */} + {Object.values(ZONES).map(zone => ( + + ))} + + {/* Agent sprites */} + {agentList.map(agent => ( + + setHoveredAgents(prev => ({ ...prev, [id]: isHovered })) + } + /> + ))} + + {/* Connection status */} + + + ); +} diff --git a/packages/frontend/src/WebSocketClient.js b/packages/frontend/src/WebSocketClient.js new file mode 100644 index 0000000..3966c52 --- /dev/null +++ b/packages/frontend/src/WebSocketClient.js @@ -0,0 +1,168 @@ +import useStore, { DEFAULT_POSITIONS } from './store.js'; + +// ── Mock activity cycle for demo mode (no backend) ──────────────── +const ACTIVITIES = ['idle', 'thinking', 'working', 'idle']; +const TASKS = [ + 'Memory maintenance', + 'Reviewing PR #42', + 'Running web search', + 'Pairing with vault', + 'Indexing documents', + 'Processing notification', + 'Training suggestion model', + 'Analyzing market data', +]; + +class WebSocketClient { + constructor() { + this.ws = null; + this.reconnectTimer = null; + this.reconnectDelay = 1000; + this.maxReconnectDelay = 30000; + this.url = 'ws://localhost:18790'; + this.mockInterval = null; + } + + connect() { + useStore.getState().setWsReconnecting(false); + + try { + this.ws = new WebSocket(this.url); + + this.ws.onopen = () => { + console.log('[WS] Connected'); + this.reconnectDelay = 1000; + }; + + this.ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + this.handleMessage(msg); + } catch (e) { + console.error('[WS] Parse error:', e); + } + }; + + this.ws.onclose = (event) => { + console.log('[WS] Disconnected:', event.code, event.reason); + useStore.getState().setWsConnected(false); + this.scheduleReconnect(); + }; + + this.ws.onerror = (err) => { + console.error('[WS] Error:', err); + }; + } catch (e) { + console.error('[WS] Connection error:', e); + this.scheduleReconnect(); + } + } + + handleMessage(msg) { + switch (msg.type) { + case 'state.full': + useStore.getState().initFromSnapshot(msg.payload); + break; + + case 'state.diff': + useStore.getState().applyDiff(msg.changes); + break; + + case 'state.init': + // Backend sent initial state before WS connection + useStore.getState().initFromSnapshot(msg.payload || msg); + break; + + case 'heartbeat': + // Heartbeat received, connection is alive + break; + + default: + console.log('[WS] Unknown message type:', msg.type, msg); + } + } + + scheduleReconnect() { + if (this.reconnectTimer) return; + + useStore.getState().setWsReconnecting(true); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.reconnectDelay = Math.min( + this.reconnectDelay * 1.5, + this.maxReconnectDelay + ); + console.log(`[WS] Reconnecting in ${this.reconnectDelay}ms...`); + setTimeout(() => this.connect(), this.reconnectDelay); + }, 500); + } + + disconnect() { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.stopMock(); + if (this.ws) { + this.ws.onclose = null; // prevent reconnect on intentional close + this.ws.close(); + this.ws = null; + } + } + + // ── Mock mode: when no backend is running, simulate agent activity ── + startMock() { + // Initialize with default agent state + useStore.getState().initFromSnapshot(null); + + // Cycle agents through activities + let cycle = 0; + const agentIds = Object.keys(DEFAULT_POSITIONS); + + this.mockInterval = setInterval(() => { + cycle++; + const store = useStore.getState(); + + // Every 3 seconds, pick 2 random agents to update + if (cycle % 3 === 0) { + const a1 = agentIds[Math.floor(Math.random() * agentIds.length)]; + let a2 = agentIds[Math.floor(Math.random() * agentIds.length)]; + while (a2 === a1 && agentIds.length > 1) { + a2 = agentIds[Math.floor(Math.random() * agentIds.length)]; + } + + const activity = ACTIVITIES[Math.floor(Math.random() * ACTIVITIES.length)]; + const task = TASKS[Math.floor(Math.random() * TASKS.length)]; + + store.updateAgentActivity(a1, activity, task); + store.updateAgentActivity(a2, + ACTIVITIES[Math.floor(Math.random() * ACTIVITIES.length)], + TASKS[Math.floor(Math.random() * TASKS.length)] + ); + } + + // Occasionally move agents + if (cycle % 10 === 0) { + const agentId = agentIds[Math.floor(Math.random() * agentIds.length)]; + const pos = store.agents[agentId]?.position; + if (pos) { + store.updateAgentPosition(agentId, { + row: Math.max(1, Math.min(18, pos.row + (Math.random() - 0.5) * 4)), + col: Math.max(1, Math.min(26, pos.col + (Math.random() - 0.5) * 6)), + }); + } + } + }, 1000); + } + + stopMock() { + if (this.mockInterval) { + clearInterval(this.mockInterval); + this.mockInterval = null; + } + } +} + +// Singleton +const wsClient = new WebSocketClient(); +export default wsClient; diff --git a/packages/frontend/src/main.jsx b/packages/frontend/src/main.jsx new file mode 100644 index 0000000..6b238c4 --- /dev/null +++ b/packages/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App.jsx'; + +const root = createRoot(document.getElementById('root')); +root.render( + + + +); diff --git a/packages/frontend/src/store.js b/packages/frontend/src/store.js new file mode 100644 index 0000000..8a88641 --- /dev/null +++ b/packages/frontend/src/store.js @@ -0,0 +1,208 @@ +import { create } from 'zustand'; + +// ── Zone definitions ─────────────────────────────────────────────── +const ZONES = { + 'pod-a': { + id: 'pod-a', + name: 'Collaboration Pod A', + type: 'collaboration', + color: '#C68B3C', + bounds: { row: 2, col: 8, rows: 5, cols: 5 }, + }, + 'pod-b': { + id: 'pod-b', + name: 'Collaboration Pod B', + type: 'collaboration', + color: '#C68B3C', + bounds: { row: 2, col: 14, rows: 5, cols: 5 }, + }, + 'solo-1': { + id: 'solo-1', + name: 'Solo Work Area 1', + type: 'solo', + color: '#3A7CA5', + bounds: { row: 2, col: 0, rows: 10, cols: 6 }, + }, + 'solo-2': { + id: 'solo-2', + name: 'Solo Work Area 2', + type: 'solo', + color: '#3A7CA5', + bounds: { row: 2, col: 22, rows: 10, cols: 6 }, + }, + 'coaching': { + id: 'coaching', + name: 'Coaching Corner', + type: 'self-improvement', + color: '#5C8A5C', + bounds: { row: 13, col: 8, rows: 4, cols: 12 }, + }, + 'coffee': { + id: 'coffee', + name: 'Coffee Bar', + type: 'social', + color: '#8B6914', + bounds: { row: 0, col: 10, rows: 2, cols: 8 }, + }, +}; + +// ── Agent definitions ────────────────────────────────────────────── +const AGENT_DEFS = { + cos: { id: 'cos', name: 'COS', department: 'cos', color: '#FF6B35' }, + engineering: { id: 'engineering', name: 'Engineering', department: 'engineering', color: '#4ECDC4' }, + 'senior-eng': { id: 'senior-eng', name: 'Senior Eng', department: 'senior-eng', color: '#7B2D8E' }, + trust: { id: 'trust', name: 'Trust', department: 'trust', color: '#45B7D1' }, + market: { id: 'market', name: 'Market', department: 'market', color: '#96CEB4' }, + health: { id: 'health', name: 'Health', department: 'health', color: '#FFEAA7' }, + content: { id: 'content', name: 'Content', department: 'content', color: '#DDA0DD' }, + presentations: { id: 'presentations', name: 'Presentations', department: 'presentations', color: '#FF8A80' }, + vault: { id: 'vault', name: 'Vault', department: 'vault', color: '#A5D6A7' }, +}; + +// ── Default agent positions (row, col on the isometric grid) ─────── +const DEFAULT_POSITIONS = { + cos: { row: 3, col: 2 }, + engineering: { row: 4, col: 10 }, + 'senior-eng': { row: 5, col: 10 }, + trust: { row: 7, col: 2 }, + market: { row: 4, col: 16 }, + health: { row: 3, col: 24 }, + content: { row: 5, col: 16 }, + presentations: { row: 6, col: 16 }, + vault: { row: 7, col: 24 }, +}; + +// ── Helpers ──────────────────────────────────────────────────────── +function generateTileMap(gridRows, gridCols) { + const tiles = []; + for (let row = 0; row < gridRows; row++) { + tiles[row] = []; + for (let col = 0; col < gridCols; col++) { + tiles[row][col] = { type: 'floor', zoneId: findZoneForTile(row, col) }; + } + } + return { rows: gridRows, cols: gridCols, tiles }; +} + +function findZoneForTile(row, col) { + for (const zone of Object.values(ZONES)) { + const { row: zr, col: zc, rows, cols } = zone.bounds; + if (row >= zr && row < zr + rows && col >= zc && col < zc + cols) { + return zone.id; + } + } + return null; +} + +// ── Store ────────────────────────────────────────────────────────── +const useStore = create((set, get) => ({ + // Connection state + wsConnected: false, + wsReconnecting: false, + + // World state + grid: generateTileMap(20, 28), + zones: ZONES, + agents: {}, + + // Initialize from full state snapshot + initFromSnapshot(snapshot) { + const agents = {}; + for (const agentId of Object.keys(AGENT_DEFS)) { + const def = AGENT_DEFS[agentId]; + const pos = DEFAULT_POSITIONS[agentId]; + agents[agentId] = { + ...def, + position: { row: pos.row, col: pos.col }, + targetPosition: { row: pos.row, col: pos.col }, + activity: 'idle', + currentTask: null, + lastHeartbeat: Date.now(), + pulse: 0, + }; + } + // Apply snapshot overrides if provided + if (snapshot && snapshot.agents) { + for (const [id, data] of Object.entries(snapshot.agents)) { + if (agents[id]) { + Object.assign(agents[id], data); + if (data.position) { + agents[id].targetPosition = { ...data.position }; + } + } + } + } + set({ agents, wsConnected: true, wsReconnecting: false }); + }, + + // Apply a diff update + applyDiff(diffs) { + const state = get(); + const updated = { ...state.agents }; + + for (const diff of diffs) { + if (diff.op === 'agent.move' || diff.op === 'agent.update') { + const agentId = diff.path.split('.')[1]; // agents.engineering → engineering + if (updated[agentId]) { + const delta = diff.value || {}; + if (delta.position) { + delta.targetPosition = { ...delta.position }; + } + Object.assign(updated[agentId], delta); + } + } else if (diff.op === 'agent.position') { + const agentId = diff.path.split('.')[1]; + if (updated[agentId]) { + updated[agentId].targetPosition = { ...diff.value }; + } + } else if (diff.op === 'agent.activity') { + const agentId = diff.path.split('.')[1]; + if (updated[agentId]) { + updated[agentId] = { ...updated[agentId], ...diff.value }; + } + } + } + + set({ agents: updated }); + }, + + // WS connection status + setWsConnected(connected) { + set({ wsConnected: connected, wsReconnecting: !connected }); + }, + + setWsReconnecting(reconnecting) { + set({ wsReconnecting: reconnecting }); + }, + + // Update agent positions directly (used by mock mode) + updateAgentPosition(agentId, position) { + set(state => { + const agents = { ...state.agents }; + if (agents[agentId]) { + agents[agentId] = { + ...agents[agentId], + targetPosition: { ...position }, + }; + } + return { agents }; + }); + }, + + updateAgentActivity(agentId, activity, task) { + set(state => { + const agents = { ...state.agents }; + if (agents[agentId]) { + agents[agentId] = { + ...agents[agentId], + activity, + currentTask: task || agents[agentId].currentTask, + }; + } + return { agents }; + }); + }, +})); + +export { ZONES, AGENT_DEFS, DEFAULT_POSITIONS }; +export default useStore; diff --git a/packages/frontend/src/vite.config.js b/packages/frontend/src/vite.config.js new file mode 100644 index 0000000..5af3800 --- /dev/null +++ b/packages/frontend/src/vite.config.js @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + root: '.', + base: '/', + server: { + port: 18790, + host: '0.0.0.0', + }, + build: { + outDir: 'dist', + emptyOutDir: true, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}); diff --git a/packages/frontend/vite.config.js b/packages/frontend/vite.config.js new file mode 100644 index 0000000..5af3800 --- /dev/null +++ b/packages/frontend/vite.config.js @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + root: '.', + base: '/', + server: { + port: 18790, + host: '0.0.0.0', + }, + build: { + outDir: 'dist', + emptyOutDir: true, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +});