chore: among other things, fixed connection issue

fix: connection issue
conditional norm now able to undo
 and are updated via pings
goals are able to be achieved out of turn

ref: N25B-400
This commit is contained in:
Pim Hutting
2026-01-16 12:57:22 +01:00
parent a98a87f8ce
commit c4e3ab27b2
5 changed files with 126 additions and 59 deletions

View File

@@ -164,6 +164,14 @@
font-size: 1.1rem;
}
.statusIndicator.clickable {
cursor: pointer;
}
.statusIndicator.clickable:hover {
transform: scale(1.2);
}
.clickable {
cursor: pointer;
}
@@ -173,7 +181,6 @@
}
.active {
cursor: default;
opacity: 1;
}

View File

@@ -2,7 +2,7 @@ import React from 'react';
import styles from './MonitoringPage.module.css';
import useProgramStore from "../../utils/programStore.ts";
import { GestureControls, SpeechPresets, DirectSpeechInput, StatusList, RobotConnected } from './MonitoringPageComponents.tsx';
import { nextPhase, useExperimentLogger, pauseExperiment, playExperiment, resetExperiment, resetPhase, type ExperimentStreamData, type GoalUpdate, type TriggerUpdate, type CondNormsStateUpdate, type PhaseUpdate } from ".//MonitoringPageAPI.ts"
import { nextPhase, useExperimentLogger, useStatusLogger, pauseExperiment, playExperiment, resetExperiment, resetPhase, type ExperimentStreamData, type GoalUpdate, type TriggerUpdate, type CondNormsStateUpdate, type PhaseUpdate } from ".//MonitoringPageAPI.ts"
import type { NormNodeData } from '../VisProgPage/visualProgrammingUI/nodes/NormNode.tsx';
@@ -35,6 +35,7 @@ export type ReducedNorm = { id: string; label?: string; norm?: string; condition
const MonitoringPage: React.FC = () => {
const getPhaseIds = useProgramStore((s) => s.getPhaseIds);
const getPhaseNames = useProgramStore((s) => s.getPhaseNames);
const getNormsInPhase = useProgramStore((s) => s.getNormsInPhase);
const getGoalsInPhase = useProgramStore((s) => s.getGoalsInPhase);
const getTriggersInPhase = useProgramStore((s) => s.getTriggersInPhase);
@@ -46,9 +47,10 @@ const MonitoringPage: React.FC = () => {
const [isPlaying, setIsPlaying] = React.useState(false);
const phaseIds = getPhaseIds();
const phaseNames = getPhaseNames();
const [phaseIndex, setPhaseIndex] = React.useState(0);
//see if we reached end node
const [isFinished, setIsFinished] = React.useState(false);
@@ -56,6 +58,7 @@ const MonitoringPage: React.FC = () => {
// Check for phase updates
if (data.type === 'phase_update' && data.id) {
const payload = data as PhaseUpdate;
console.log(`${data.type} received, id : ${data.id}`)
if (payload.id === "end") {
setIsFinished(true);
} else {
@@ -73,8 +76,10 @@ const MonitoringPage: React.FC = () => {
const payload = data as GoalUpdate;
const currentPhaseGoals = getGoalsInPhase(phaseIds[phaseIndex]) as ReducedGoal[];
const gIndex = currentPhaseGoals.findIndex((g: ReducedGoal) => g.id === payload.id);
if (gIndex !== -1) {
console.log(`${data.type} received, id : ${data.id}`)
if (gIndex == -1)
{console.log(`goal to update with id ${payload.id} not found in current phase ${phaseNames[phaseIndex]}`)}
else {
//set current goal to the goal that is just started
setGoalIndex(gIndex);
@@ -101,24 +106,33 @@ const MonitoringPage: React.FC = () => {
...prev,
[payload.id]: payload.achieved
}));
}
else if (data.type === 'cond_norms_state_update') {
}
}, [getPhaseIds, getGoalsInPhase, phaseIds, phaseIndex]);
const handleStatusUpdate = React.useCallback((data: any) => {
if (data.type === 'cond_norms_state_update') {
const payload = data as CondNormsStateUpdate;
setActiveIds((prev) => {
const hasChanges = payload.norms.some(
(normUpdate) => prev[normUpdate.id] !== normUpdate.active
);
if (!hasChanges) {
return prev;
}
const nextState = { ...prev };
// payload.norms is typed on the union, so safe to use directly
payload.norms.forEach((normUpdate) => {
nextState[normUpdate.id] = normUpdate.active;
});
return nextState;
});
//commented out to avoid cluttering
//console.log("Updated conditional norms state:", payload.norms);
}
}, [getPhaseIds, getGoalsInPhase, phaseIds, phaseIndex]);
}, []);
//For incoming phase, goals and trigger updates
useExperimentLogger(handleStreamUpdate);
//For pings that update conditional norms
useStatusLogger(handleStatusUpdate);
if (phaseIds.length === 0) {
return <p className={styles.empty}>No program loaded.</p>;
@@ -229,19 +243,40 @@ const MonitoringPage: React.FC = () => {
<header className={styles.experimentOverview}>
<div className={styles.phaseName}>
<h2>Experiment Overview</h2>
<p><strong>Phase</strong> {` ${phaseIndex + 1}`} </p>
<p>
{isFinished ? (
<strong>Experiment finished</strong>
) : (
<>
<strong>Phase {phaseIndex + 1}:</strong> {phaseNames[phaseIndex]}
</>
)}
</p>
<div className={styles.phaseProgress}>
{phaseIds.map((id, index) => (
<span
key={id}
className={`${styles.phase} ${
index < phaseIndex ? styles.completed :
index === phaseIndex ? styles.current : ""
}`}
>
{index + 1}
</span>
))}
{phaseIds.map((id, index) => {
// Determine the status of the phase indicator
let phaseStatusClass = "";
if (isFinished) {
// If the whole experiment is done, all squares are green (completed)
phaseStatusClass = styles.completed;
} else if (index < phaseIndex) {
// Past phases
phaseStatusClass = styles.completed;
} else if (index === phaseIndex) {
// The current phase being worked on
phaseStatusClass = styles.current;
}
return (
<span
key={id}
className={`${styles.phase} ${phaseStatusClass}`}
>
{index + 1}
</span>
);
})}
</div>
</div>
@@ -314,7 +349,7 @@ const MonitoringPage: React.FC = () => {
</div>
) : (
<>
<StatusList title="Goals" items={goals} type="goal" activeIds={activeIds} currentGoalIndex={goalIndex} />
<StatusList title="Goals" items={goals} type="goal" activeIds={activeIds} setActiveIds = {setActiveIds} currentGoalIndex={goalIndex} />
<StatusList title="Triggers" items={triggers} type="trigger" activeIds={activeIds} />
<StatusList title="Norms" items={norms} type="norm" activeIds={activeIds} />
<StatusList title="Conditional Norms" items={conditionalNorms} type="cond_norm" activeIds={activeIds} />

View File

@@ -1,4 +1,5 @@
import { useEffect } from 'react';
import React, { useEffect } from 'react';
import { data } from 'react-router';
const API_BASE = "http://localhost:8000";
const API_BASE_BP = API_BASE + "/button_pressed"; //UserInterruptAgent endpoint
@@ -6,26 +7,7 @@ const API_BASE_BP = API_BASE + "/button_pressed"; //UserInterruptAgent endpoint
/**
* HELPER: Unified sender function
*/
export const sendUserInterrupt = async (type: string, context: string) => {
try {
const response = await fetch(API_BASE_BP, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({type, context}),
});
if (!response.ok) throw new Error("Backend response error");
console.log(`Interrupt Sent - Type: ${type}, Context: ${context}`);
} catch (err) {
console.error(`Failed to send interrupt:`, err);
}
};
/**
* HELPER: Unified sender function
* In a real app, you might move this to a /services or /hooks folder
*/
const sendAPICall = async (type: string, context: string, endpoint?: string) => {
export const sendAPICall = async (type: string, context: string, endpoint?: string) => {
try {
const response = await fetch(`${API_BASE_BP}${endpoint ?? ""}`, {
method: "POST",
@@ -98,25 +80,56 @@ export type ExperimentStreamData = PhaseUpdate | GoalUpdate | TriggerUpdate | Co
* via updates sent from the backend
*/
export function useExperimentLogger(onUpdate?: (data: ExperimentStreamData) => void) {
const callbackRef = React.useRef(onUpdate);
// Ref is updated every time with on update
React.useEffect(() => {
callbackRef.current = onUpdate;
}, [onUpdate]);
useEffect(() => {
console.log("Connecting to Experiment Stream...");
const eventSource = new EventSource(`${API_BASE}/experiment_stream`);
eventSource.onmessage = (event) => {
try {
const parsedData = JSON.parse(event.data) as ExperimentStreamData;
if (onUpdate) {
onUpdate(parsedData);
}
//call function using the ref
callbackRef.current?.(parsedData);
} catch (err) {
console.warn("Stream parse error:", err);
}
};
eventSource.onerror = (err) => {
console.error("SSE Connection Error:", err);
eventSource.close();
};
return () => {
console.log("Closing Experiment Stream...");
eventSource.close();
};
}, []);
}
/**
* A hook that listens to the status stream that updates active conditional norms
* via updates sent from the backend
*/
export function useStatusLogger(onUpdate?: (data: any) => void) {
const callbackRef = React.useRef(onUpdate);
React.useEffect(() => {
callbackRef.current = onUpdate;
}, [onUpdate]);
useEffect(() => {
const eventSource = new EventSource(`${API_BASE}/status_stream`);
eventSource.onmessage = (event) => {
try {
const parsedData = JSON.parse(event.data);
callbackRef.current?.(parsedData);
} catch (err) { console.warn("Status stream error:", err); }
};
return () => eventSource.close();
}, []); // LEGE dependency array
}

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import styles from './MonitoringPage.module.css';
import { sendUserInterrupt } from './MonitoringPageAPI';
import { sendAPICall } from './MonitoringPageAPI';
// --- GESTURE COMPONENT ---
export const GestureControls: React.FC = () => {
@@ -23,7 +23,7 @@ export const GestureControls: React.FC = () => {
>
{gestures.map(g => <option key={g.value} value={g.value}>{g.label}</option>)}
</select>
<button onClick={() => sendUserInterrupt("gesture", selectedGesture)}>
<button onClick={() => sendAPICall("gesture", selectedGesture)}>
Actuate
</button>
</div>
@@ -47,7 +47,7 @@ export const SpeechPresets: React.FC = () => {
<li key={i}>
<button
className={styles.speechBtn}
onClick={() => sendUserInterrupt("speech", phrase.text)}
onClick={() => sendAPICall("speech", phrase.text)}
>
"{phrase.label}"
</button>
@@ -64,7 +64,7 @@ export const DirectSpeechInput: React.FC = () => {
const handleSend = () => {
if (!text.trim()) return;
sendUserInterrupt("speech", text);
sendAPICall("speech", text);
setText(""); // Clear after sending
};
@@ -92,6 +92,7 @@ type StatusItem = {
description?: string;
label?: string;
norm?: string;
name?: string;
};
interface StatusListProps {
@@ -99,6 +100,7 @@ interface StatusListProps {
items: StatusItem[];
type: 'goal' | 'trigger' | 'norm'| 'cond_norm';
activeIds: Record<string, boolean>;
setActiveIds?: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
currentGoalIndex?: number;
}
@@ -108,6 +110,7 @@ export const StatusList: React.FC<StatusListProps> = ({
items,
type,
activeIds,
setActiveIds,
currentGoalIndex // Destructure this prop
}) => {
return (
@@ -118,19 +121,23 @@ export const StatusList: React.FC<StatusListProps> = ({
if (item.id === undefined) return null;
const isActive = !!activeIds[item.id];
const showIndicator = type !== 'norm';
const canOverride = showIndicator && !isActive || (type === 'cond_norm' && isActive);
const isCurrentGoal = type === 'goal' && idx === currentGoalIndex;
const canOverride = (showIndicator && !isActive) || (type === 'cond_norm' && isActive);
const handleOverrideClick = () => {
if (!canOverride) return;
if (type === 'cond_norm' && isActive){
{/* Unachieve conditional norm */}
sendUserInterrupt("override_unachieve", String(item.id));
sendAPICall("override_unachieve", String(item.id));
}
else {
if(type === 'goal')
if(setActiveIds)
{setActiveIds(prev => ({ ...prev, [String(item.id)]: true }));}
sendUserInterrupt("override", String(item.id));
sendAPICall("override", String(item.id));
}
};
@@ -156,7 +163,7 @@ export const StatusList: React.FC<StatusListProps> = ({
borderRadius: '4px'
}}
>
{item.description || item.label || item.norm}
{item.name || item.description || item.label || item.norm}
{isCurrentGoal && " (Current)"}
</span>
</li>

View File

@@ -15,6 +15,7 @@ export type ProgramState = {
// Utility functions:
// to avoid having to manually go through the entire state for every instance where data is required
getPhaseIds: () => string[];
getPhaseNames: () => string[];
getNormsInPhase: (currentPhaseId: string) => Record<string, unknown>[];
getGoalsInPhase: (currentPhaseId: string) => Record<string, unknown>[];
getTriggersInPhase: (currentPhaseId: string) => Record<string, unknown>[];
@@ -43,6 +44,10 @@ const useProgramStore = create<ProgramState>((set, get) => ({
* gets the ids of all phases in the program
*/
getPhaseIds: () => get().currentProgram.phases.map(entry => entry["id"] as string),
/**
* gets the names of all phases in the program
*/
getPhaseNames: () => get().currentProgram.phases.map((entry) => (entry["name"] as string)),
/**
* gets the norms for the provided phase
*/