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 (
+
+
+
+
+
+ );
+}
+
+// ── 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'),
+ },
+ },
+});