Files
the-matrix/js/interaction.js
Alexander Whitestone 647b0669de feat: click-to-view-PR panel for agents (#8)
Clicking/tapping an agent in the 3D world now slides in a panel showing
that agent's current Gitea issue, active branch, open PR link, and last
5 commits.

- js/agent-panel.js (new): fetches Gitea API on demand; AbortController
  cancels stale requests; graceful fallback when gitea URL / gitLogin
  are unset
- js/interaction.js: adds initClickDetection() using THREE.Raycaster;
  distinguishes clicks from orbit-control drags (< 6px pointer travel)
- js/agents.js: sets group.userData.agentId for raycasting; exports
  getAgentGroups()
- js/agent-defs.js: adds optional gitLogin field per agent
- js/config.js: adds giteaUrl / giteaToken / giteaRepo config fields
  (VITE_GITEA_URL, VITE_GITEA_TOKEN, VITE_GITEA_REPO env vars)
- index.html: #agent-panel slides in from right edge; Esc / ✕ closes it

Fixes #8

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 14:04:50 -04:00

96 lines
2.7 KiB
JavaScript

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
let controls;
export function initInteraction(camera, renderer) {
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.screenSpacePanning = false;
controls.minDistance = 5;
controls.maxDistance = 80;
controls.maxPolarAngle = Math.PI / 2.1;
controls.target.set(0, 0, 0);
controls.update();
renderer.domElement.addEventListener('contextmenu', e => e.preventDefault());
}
export function updateControls() {
if (controls) controls.update();
}
/**
* Dispose orbit controls (used on world teardown).
*/
export function disposeInteraction() {
if (controls) {
controls.dispose();
controls = null;
}
}
/**
* Set up pointer-based click detection for agent meshes.
* Distinguishes clicks from orbit-control drags (pointer must move < 6px).
*
* @param {THREE.Camera} camera
* @param {THREE.WebGLRenderer} renderer
* @param {THREE.Object3D[]} agentGroups — array of agent group objects to raycast against
* @param {function} onAgentClick — called with agentId string on hit
* @returns {function} cleanup — call to remove event listeners
*/
export function initClickDetection(camera, renderer, agentGroups, onAgentClick) {
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let downX = 0, downY = 0;
function toNDC(clientX, clientY) {
const rect = renderer.domElement.getBoundingClientRect();
return {
x: ((clientX - rect.left) / rect.width) * 2 - 1,
y: -((clientY - rect.top) / rect.height) * 2 + 1,
};
}
function onPointerDown(e) {
downX = e.clientX;
downY = e.clientY;
}
function onPointerUp(e) {
const dx = e.clientX - downX;
const dy = e.clientY - downY;
if (dx * dx + dy * dy > 36) return; // drag threshold 6px
const ndc = toNDC(e.clientX, e.clientY);
pointer.set(ndc.x, ndc.y);
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(agentGroups, true);
if (hits.length > 0) {
const agentId = findAgentId(hits[0].object);
if (agentId) onAgentClick(agentId);
}
}
const el = renderer.domElement;
el.addEventListener('pointerdown', onPointerDown);
el.addEventListener('pointerup', onPointerUp);
return () => {
el.removeEventListener('pointerdown', onPointerDown);
el.removeEventListener('pointerup', onPointerUp);
};
}
function findAgentId(object) {
let o = object;
while (o) {
if (o.userData?.agentId) return o.userData.agentId;
o = o.parent;
}
return null;
}