Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CONTINUITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Key decisions:
- React UI is primary; server should serve React build when present.
- Agent lane follows vanilla JS behavior: show all open sessions (active + idle) and sort by state rank then CPU.
- Layout uses pinned positions per agent identity; only new agents are placed; existing tiles never move; placement uses max-height bounds to prevent overlap.
- Scale placement with spatial hash buckets (cell -> occupants) and persistent per-group spiral frontier; collisions checked by bounds against bucket occupants.
- Codex prompt-like titles containing temp paths/turn markers are ignored for lane labels; fallback to repo or codex#pid.
- Agent lane now shows only active/error sessions and uses stable sort (state rank then identity) to prevent reordering.
- Active lane items now glow via `is-active` class (lane item box-shadow) to make active codex sessions visibly highlighted.
Expand Down Expand Up @@ -133,6 +134,11 @@ State:
- Tests re-run: `npm run test:unit` (149 pass), `npm run test:integration` (54 pass), `npm run test:ui` (19 pass).
- Build run after pinned layout changes: `npm run build` (pass).
- Copied edited files to clipboard via pbcopy; temp bundle at `/tmp/consensus-edited-files.txt`.
- Implemented spatial hash buckets + persistent group spiral frontier; fixed cell packing and bounds-based collision checks (public/src/lib/layout.ts).
- Renderer now keeps a single RAF loop, exposes hit list, and uses correct view transform for hit tests (public/src/hooks/useCanvasRenderer.ts).
- CanvasScene no longer rebuilds hit list; uses renderer hit list and stable render loop (public/src/components/CanvasScene.tsx).
- Obstruction detection checks only front occluders with AABB guard (public/src/hooks/useCanvasRenderer.ts).
- Tests run after layout/renderer updates: `npm run test:unit` (152 pass), `npm run test:integration` (58 pass), `npm run test:ui` (19 pass).
- Now:
- Answer user question about hot reload configuration (Vite HMR + server watch + live reload SSE).
- Answer whether any additional setup is missing when using Vite.
Expand Down Expand Up @@ -161,6 +167,7 @@ State:
- Claude CLI hooks not firing for real sessions; user reports Claude does not show active when working.
- Need to validate live Claude session while recording via agent-browser and inspect hook delivery.
- Copy edited layout/test files to clipboard via pbcopy. (DONE)
- Implement spatial hash buckets + persistent group frontier with correct cell packing and bounds checks. (DONE)
- User requested a best-practice plan (no code edits) to address review comment: `npm run dev` no longer starts Vite, so TSX client fails unless dev:client or build is run.
- Q&A answered for dev workflow: always start Vite; use CONSENSUS_UI_PORT for port; on port conflict try next port.
- Review plan requested for new P2/P3 items: Codex in-flight timeout default in codexLogs; TOML [tui] notifications insertion in cli/setup.
Expand Down
182 changes: 14 additions & 168 deletions public/src/components/CanvasScene.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,10 @@
import { useRef, useEffect, useLayoutEffect, useCallback, useState } from 'react';
import type { AgentSnapshot, ViewState } from '../types';
import { useCanvasRenderer } from '../hooks/useCanvasRenderer';
import { createLayoutState, updateLayout, getCoordinate } from '../lib/layout';
import { agentIdentity, keyForAgent } from '../lib/format';
import { pointInDiamond, pointInQuad, isoToScreen } from '../lib/iso';
import { createLayoutState, updateLayout } from '../lib/layout';
import { agentIdentity } from '../lib/format';
import { Tooltip } from './Tooltip';

const TILE_W = 96;
const TILE_H = 48;
const ROOF_SCALE = 0.28;
const ROOF_HIT_SCALE = 0.44;
const MARKER_SCALE = 0.36;
const MARKER_OFFSET = TILE_H * 0.6;

interface HitItem {
x: number;
y: number;
roofY: number;
roofW: number;
roofH: number;
roofHitW: number;
roofHitH: number;
height: number;
agent: AgentSnapshot;
key: string;
markerY?: number;
}

interface CanvasSceneProps {
agents: AgentSnapshot[];
view: ViewState;
Expand All @@ -42,21 +20,19 @@ export function CanvasScene({
agents,
view,
selected,
searchMatches,
searchMatches: _searchMatches,
onSelect,
onMouseDown,
onKeyDown,
onWheel,
}: CanvasSceneProps) {
const containerRef = useRef<HTMLDivElement>(null);
const layoutRef = useRef(createLayoutState());
const spawnTimesRef = useRef<Map<string, number>>(new Map());
const knownIdsRef = useRef<Set<string>>(new Set());
const [hovered, setHovered] = useState<AgentSnapshot | null>(null);
const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 });
const hitListRef = useRef<HitItem[]>([]);

const { canvasRef, startRender, stopRender } = useCanvasRenderer();
const { canvasRef, startRender, stopRender, getAgentAtPoint, getHitList } = useCanvasRenderer();

// Update layout and spawn times when agents change
useLayoutEffect(() => {
Expand All @@ -81,162 +57,36 @@ export function CanvasScene({
updateLayout(layoutRef.current, agents);
}, [agents]);

// Rebuild hit list whenever agents or view changes
useEffect(() => {
const roofW = TILE_W * ROOF_SCALE;
const roofH = roofW * 0.5;
const roofHitW = TILE_W * ROOF_HIT_SCALE;
const roofHitH = roofHitW * 0.5;

// Sort by depth (same as render order)
const sortedAgents = [...agents].sort((a, b) => {
const coordA = getCoordinate(layoutRef.current, a) ?? { x: 0, y: 0 };
const coordB = getCoordinate(layoutRef.current, b) ?? { x: 0, y: 0 };
return coordA.x + coordA.y - (coordB.x + coordB.y);
});

const hitList: HitItem[] = [];

for (const agent of sortedAgents) {
const coord = getCoordinate(layoutRef.current, agent);
if (!coord) continue;

const screen = isoToScreen(coord.x, coord.y, TILE_W, TILE_H);
const memMB = agent.mem / (1024 * 1024);
const heightBase = Math.min(120, Math.max(18, memMB * 0.4));
const idleScale = agent.state === 'idle' ? 0.6 : 1;
const height = heightBase * idleScale;
const roofY = screen.y - height - TILE_H * 0.15;

hitList.push({
x: screen.x,
y: screen.y,
roofY,
roofW,
roofH,
roofHitW,
roofHitH,
height,
agent,
key: keyForAgent(agent),
});
}

// Calculate obstruction and marker positions
const obstructedIds = new Set<string>();
for (const a of hitList) {
const roofPoint = { x: a.x, y: a.roofY };
for (const b of hitList) {
if (a === b) continue;
const topY = b.y - b.height;
const halfW = TILE_W / 2;
const halfH = TILE_H / 2;

const leftA = { x: b.x - halfW, y: topY };
const leftB = { x: b.x, y: topY + halfH };
const leftC = { x: b.x, y: b.y + halfH };
const leftD = { x: b.x - halfW, y: b.y };

const rightA = { x: b.x + halfW, y: topY };
const rightB = { x: b.x, y: topY + halfH };
const rightC = { x: b.x, y: b.y + halfH };
const rightD = { x: b.x + halfW, y: b.y };

if (
pointInQuad(roofPoint, leftA, leftB, leftC, leftD) ||
pointInQuad(roofPoint, rightA, rightB, rightC, rightD)
) {
obstructedIds.add(agentIdentity(a.agent));
break;
}
}
}

for (const item of hitList) {
if (obstructedIds.has(agentIdentity(item.agent))) {
item.markerY = item.roofY - MARKER_OFFSET;
}
}

hitListRef.current = hitList;
}, [agents, view]);

useEffect(() => {
const win = window as any;
if (!win.__consensusMock) {
win.__consensusMock = {};
}
win.__consensusMock.getHitList = () => hitListRef.current;
win.__consensusMock.getHitList = () => getHitList();
win.__consensusMock.getView = () => ({ x: view.x, y: view.y, scale: view.scale });
}, [view]);
}, [view, getHitList]);

// Start/stop render loop
useEffect(() => {
if (!canvasRef.current) return;

startRender(view, agents, {
layout: layoutRef.current,
hovered,
selected,
spawnTimes: spawnTimesRef.current,
deviceScale: window.devicePixelRatio || 1,
});
}, [agents, view, hovered, selected, startRender]);

useEffect(() => {
return () => {
stopRender();
};
}, [agents, view, hovered, selected, searchMatches, startRender, stopRender, canvasRef]);

const findAgentAt = useCallback((canvasX: number, canvasY: number): AgentSnapshot | null => {
// Transform canvas coordinates to world coordinates
const worldX = (canvasX - view.x) / view.scale;
const worldY = (canvasY - view.y) / view.scale;

const markerW = TILE_W * MARKER_SCALE;
const markerH = markerW * 0.5;
const hitList = hitListRef.current;

if (!hitList.length) return null;

// Check markers first (for obstructed agents)
for (let i = hitList.length - 1; i >= 0; i--) {
const item = hitList[i];
if (!item.markerY) continue;
if (pointInDiamond(worldX, worldY, item.x, item.markerY, markerW, markerH)) {
return item.agent;
}
}

// Check roofs
for (let i = hitList.length - 1; i >= 0; i--) {
const item = hitList[i];
if (pointInDiamond(worldX, worldY, item.x, item.roofY, item.roofHitW, item.roofHitH)) {
return item.agent;
}
}

// Check base tiles
for (let i = hitList.length - 1; i >= 0; i--) {
const item = hitList[i];
if (pointInDiamond(worldX, worldY, item.x, item.y, TILE_W, TILE_H)) {
return item.agent;
}
}

return null;
}, [view]);
}, [stopRender]);

const handleMouseMove = useCallback((e: React.MouseEvent) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;

const x = e.clientX - rect.left;
const y = e.clientY - rect.top;

const found = findAgentAt(x, y);
const found = getAgentAtPoint(e.clientX, e.clientY);
setHovered(found);
setTooltipPos({ x: e.clientX + 12, y: e.clientY + 12 });
}, [findAgentAt]);
}, [getAgentAtPoint]);

const handleMouseLeave = useCallback(() => {
setHovered(null);
Expand All @@ -249,18 +99,14 @@ export function CanvasScene({
return;
}

// Calculate from click position (for direct clicks without hover)
const rect = (e.target as HTMLCanvasElement).getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const found = findAgentAt(x, y);
const found = getAgentAtPoint(e.clientX, e.clientY);
if (found) {
onSelect(found);
}
}, [hovered, onSelect, findAgentAt]);
}, [hovered, onSelect, getAgentAtPoint]);

return (
<div ref={containerRef} className="canvas-container">
<div className="canvas-container">
<canvas
id="scene"
ref={canvasRef}
Expand Down
Loading
Loading