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
This commit is contained in:
parent
17d8d21704
commit
03eaea3f2d
10 changed files with 1042 additions and 13 deletions
40
packages/frontend/index.html
Normal file
40
packages/frontend/index.html
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Spatial Office — Scobleclaw</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body, #root { width: 100%; height: 100%; overflow: hidden; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
#root { position: relative; }
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #1a1a2e;
|
||||
z-index: 100;
|
||||
}
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #333;
|
||||
border-top-color: #4ECDC4;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
190
packages/frontend/src/AgentSprite.jsx
Normal file
190
packages/frontend/src/AgentSprite.jsx
Normal file
|
|
@ -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 (
|
||||
<group>
|
||||
{/* Agent body — isometric cube */}
|
||||
<mesh
|
||||
ref={meshRef}
|
||||
position={targetPos || [0, 0.15, 0]}
|
||||
onPointerEnter={(e) => {
|
||||
e.stopPropagation();
|
||||
onHover(agent.id, true);
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
e.stopPropagation();
|
||||
onHover(agent.id, false);
|
||||
}}
|
||||
castShadow
|
||||
>
|
||||
<boxGeometry args={[0.4, 0.5, 0.3]} />
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
emissive={color}
|
||||
emissiveIntensity={agent.activity === 'offline' ? 0 : 0.3}
|
||||
roughness={0.6}
|
||||
metalness={0.1}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Glow ring below agent */}
|
||||
<mesh
|
||||
ref={glowRef}
|
||||
position={
|
||||
targetPos
|
||||
? [targetPos[0], 0.02, targetPos[2]]
|
||||
: [0, 0.02, 0]
|
||||
}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<ringGeometry args={[0.25, 0.35, 32]} />
|
||||
<meshBasicMaterial
|
||||
color={glowColor}
|
||||
transparent
|
||||
opacity={0.2}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Agent name label */}
|
||||
<Text
|
||||
position={
|
||||
targetPos
|
||||
? [targetPos[0], 0.7, targetPos[2]]
|
||||
: [0, 0.7, 0]
|
||||
}
|
||||
fontSize={0.15}
|
||||
color={agent.activity === 'offline' ? '#888888' : '#ffffff'}
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.01}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
{agent.name}
|
||||
</Text>
|
||||
|
||||
{/* Hover tooltip */}
|
||||
{hovered && (
|
||||
<Html
|
||||
position={
|
||||
targetPos
|
||||
? [targetPos[0], 0.9, targetPos[2]]
|
||||
: [0, 0.9, 0]
|
||||
}
|
||||
center
|
||||
distanceFactor={15}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
<div style={{
|
||||
background: 'rgba(20,20,40,0.95)',
|
||||
color: '#eee',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
whiteSpace: 'nowrap',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
}}>
|
||||
<div style={{ fontWeight: 'bold', color: agent.color, marginBottom: 4 }}>
|
||||
{agent.name}
|
||||
</div>
|
||||
<div style={{ opacity: 0.8 }}>
|
||||
Status: <span style={{ color: glowColor }}>{agent.activity}</span>
|
||||
</div>
|
||||
{agent.currentTask && (
|
||||
<div style={{ opacity: 0.7, marginTop: 2 }}>
|
||||
{agent.currentTask}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Html>
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
176
packages/frontend/src/App.jsx
Normal file
176
packages/frontend/src/App.jsx
Normal file
|
|
@ -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 (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 40,
|
||||
background: 'rgba(0,0,0,0.8)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 16,
|
||||
padding: '0 16px',
|
||||
fontSize: 11,
|
||||
color: '#999',
|
||||
borderTop: '1px solid #333',
|
||||
zIndex: 10,
|
||||
}}>
|
||||
<span>Spatial Office</span>
|
||||
<span>|</span>
|
||||
<span>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
background: connected ? '#44cc44' : '#ff4444',
|
||||
marginRight: 4,
|
||||
verticalAlign: 'middle',
|
||||
}} />
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
<span>|</span>
|
||||
<span>{Object.keys(agents).length} agents</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
|
||||
<Canvas
|
||||
shadows
|
||||
gl={{
|
||||
antialias: true,
|
||||
toneMapping: THREE.ACESFilmicToneMapping,
|
||||
toneMappingExposure: 1.2,
|
||||
}}
|
||||
camera={{
|
||||
position: [18, 20, 18],
|
||||
near: 0.1,
|
||||
far: 200,
|
||||
}}
|
||||
style={{ background: '#1a1a2e' }}
|
||||
>
|
||||
{/* Orthographic camera for true isometric */}
|
||||
<OrthoCamera />
|
||||
|
||||
{/* Lighting */}
|
||||
<ambientLight intensity={0.4} />
|
||||
<directionalLight
|
||||
position={[10, 20, 5]}
|
||||
intensity={0.8}
|
||||
castShadow
|
||||
shadow-mapSize-width={1024}
|
||||
shadow-mapSize-height={1024}
|
||||
/>
|
||||
<hemisphereLight
|
||||
args={['#8ec8f2', '#332211', 0.3]}
|
||||
/>
|
||||
|
||||
{/* Limited orbit controls — zoom + pan, but locked rotation for isometric feel */}
|
||||
<OrbitControls
|
||||
enableRotate={true}
|
||||
enableZoom={true}
|
||||
enablePan={true}
|
||||
minZoom={5}
|
||||
maxZoom={40}
|
||||
maxPolarAngle={Math.PI / 2.2}
|
||||
dampingFactor={0.1}
|
||||
target={[0, 0, 6]}
|
||||
/>
|
||||
|
||||
{/* Office scene */}
|
||||
<OfficeScene />
|
||||
</Canvas>
|
||||
|
||||
<UIOverlay />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 <orthographicCamera ref={ref} />;
|
||||
}
|
||||
195
packages/frontend/src/OfficeScene.jsx
Normal file
195
packages/frontend/src/OfficeScene.jsx
Normal file
|
|
@ -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 (
|
||||
<group>
|
||||
{/* Floor diamond */}
|
||||
<mesh
|
||||
position={[wx, 0, wz]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
receiveShadow
|
||||
>
|
||||
<shapeGeometry args={[shape]} />
|
||||
<meshStandardMaterial
|
||||
color={color}
|
||||
roughness={0.9}
|
||||
metalness={0}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Grid line border */}
|
||||
<lineLoop position={[wx, 0.001, wz]}>
|
||||
<bufferGeometry>
|
||||
<bufferAttribute
|
||||
attach="attributes-position"
|
||||
count={5}
|
||||
array={new Float32Array([
|
||||
-TILE_W / 2, 0, 0,
|
||||
0, 0, -TILE_H / 2,
|
||||
TILE_W / 2, 0, 0,
|
||||
0, 0, TILE_H / 2,
|
||||
-TILE_W / 2, 0, 0,
|
||||
])}
|
||||
itemSize={3}
|
||||
/>
|
||||
</bufferGeometry>
|
||||
<lineBasicMaterial color={GRID_LINE_COLOR} transparent opacity={0.3} />
|
||||
</lineLoop>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<Html position={[wx, 0.5, wz]} center distanceFactor={30} style={{ pointerEvents: 'none' }}>
|
||||
<div style={{
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
color: '#ffffffcc',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
border: `1px solid ${zone.color}44`,
|
||||
}}>
|
||||
{zone.name}
|
||||
</div>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Connection indicator ────────────────────────────────────────
|
||||
function ConnectionIndicator() {
|
||||
const connected = useStore(s => s.wsConnected);
|
||||
const reconnecting = useStore(s => s.wsReconnecting);
|
||||
|
||||
return (
|
||||
<Html fullscreen style={{ pointerEvents: 'none' }}>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
padding: '6px 14px',
|
||||
borderRadius: 20,
|
||||
fontSize: 12,
|
||||
color: '#ccc',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: connected ? '#44cc44' : reconnecting ? '#ffcc00' : '#ff4444',
|
||||
boxShadow: `0 0 6px ${connected ? '#44cc44' : reconnecting ? '#ffcc00' : '#ff4444'}`,
|
||||
}} />
|
||||
{connected ? 'Connected' : reconnecting ? 'Reconnecting...' : 'Disconnected'}
|
||||
</div>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<group>
|
||||
{/* Base ground plane */}
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.01, 0]} receiveShadow>
|
||||
<planeGeometry args={[30, 20]} />
|
||||
<meshStandardMaterial color="#111122" roughness={1} />
|
||||
</mesh>
|
||||
|
||||
{/* Floor tiles */}
|
||||
{tiles.map(({ row, col, zoneId }) => (
|
||||
<FloorTile key={`${row}-${col}`} row={row} col={col} zoneId={zoneId} />
|
||||
))}
|
||||
|
||||
{/* Zone labels */}
|
||||
{Object.values(ZONES).map(zone => (
|
||||
<ZoneLabel key={zone.id} zone={zone} />
|
||||
))}
|
||||
|
||||
{/* Agent sprites */}
|
||||
{agentList.map(agent => (
|
||||
<AgentSprite
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
hovered={!!hoveredAgents[agent.id]}
|
||||
onHover={(id, isHovered) =>
|
||||
setHoveredAgents(prev => ({ ...prev, [id]: isHovered }))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Connection status */}
|
||||
<ConnectionIndicator />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
168
packages/frontend/src/WebSocketClient.js
Normal file
168
packages/frontend/src/WebSocketClient.js
Normal file
|
|
@ -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;
|
||||
10
packages/frontend/src/main.jsx
Normal file
10
packages/frontend/src/main.jsx
Normal file
|
|
@ -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(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
208
packages/frontend/src/store.js
Normal file
208
packages/frontend/src/store.js
Normal file
|
|
@ -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;
|
||||
22
packages/frontend/src/vite.config.js
Normal file
22
packages/frontend/src/vite.config.js
Normal file
|
|
@ -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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
22
packages/frontend/vite.config.js
Normal file
22
packages/frontend/vite.config.js
Normal file
|
|
@ -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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue