feat: implement WebSocket for real-time trial updates

- Create standalone WebSocket server (ws-server.ts) on port 3001 using Bun
- Add ws_connections table to track active connections in database
- Create global WebSocket manager that persists across component unmounts
- Fix useWebSocket hook to prevent infinite re-renders and use refs
- Fix TrialForm Select components with proper default values
- Add trialId to WebSocket URL for server-side tracking
- Update package.json with dev:ws script for separate WS server
This commit is contained in:
Sean O'Connor
2026-03-22 00:48:43 -04:00
parent 20d6d3de1a
commit a5762ec935
9 changed files with 1257 additions and 481 deletions

View File

@@ -1,7 +1,5 @@
"use client";
/* eslint-disable react-hooks/exhaustive-deps */
import { useSession } from "~/lib/auth-client";
import { useCallback, useEffect, useRef, useState } from "react";
@@ -56,10 +54,42 @@ interface TrialActionExecutedMessage {
interface InterventionLoggedMessage {
type: "intervention_logged";
data: {
intervention?: unknown;
timestamp: number;
} & Record<string, unknown>;
}
interface TrialEventMessage {
type: "trial_event";
data: {
event: unknown;
timestamp: number;
};
}
interface TrialEventsSnapshotMessage {
type: "trial_events_snapshot";
data: {
events: unknown[];
timestamp: number;
};
}
interface AnnotationAddedMessage {
type: "annotation_added";
data: {
annotation: unknown;
timestamp: number;
};
}
interface PongMessage {
type: "pong";
data: {
timestamp: number;
};
}
interface StepChangedMessage {
type: "step_changed";
data: {
@@ -83,6 +113,10 @@ type KnownInboundMessage =
| TrialStatusMessage
| TrialActionExecutedMessage
| InterventionLoggedMessage
| TrialEventMessage
| TrialEventsSnapshotMessage
| AnnotationAddedMessage
| PongMessage
| StepChangedMessage
| ErrorMessage;
@@ -98,18 +132,247 @@ export interface OutgoingMessage {
data: Record<string, unknown>;
}
export interface UseWebSocketOptions {
interface Subscription {
trialId: string;
onMessage?: (message: WebSocketMessage) => void;
onConnect?: () => void;
onDisconnect?: () => void;
onError?: (error: Event) => void;
reconnectAttempts?: number;
reconnectInterval?: number;
heartbeatInterval?: number;
}
export interface UseWebSocketReturn {
interface GlobalWSState {
isConnected: boolean;
isConnecting: boolean;
connectionError: string | null;
lastMessage: WebSocketMessage | null;
}
type StateListener = (state: GlobalWSState) => void;
class GlobalWebSocketManager {
private ws: WebSocket | null = null;
private subscriptions: Map<string, Subscription> = new Map();
private stateListeners: Set<StateListener> = new Set();
private sessionRef: { user: { id: string } } | null = null;
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
private attemptCount = 0;
private maxAttempts = 5;
private state: GlobalWSState = {
isConnected: false,
isConnecting: false,
connectionError: null,
lastMessage: null,
};
private setState(partial: Partial<GlobalWSState>) {
this.state = { ...this.state, ...partial };
this.notifyListeners();
}
private notifyListeners() {
this.stateListeners.forEach((listener) => listener(this.state));
}
subscribe(
session: { user: { id: string } } | null,
subscription: Subscription,
) {
this.sessionRef = session;
this.subscriptions.set(subscription.trialId, subscription);
if (this.subscriptions.size === 1 && !this.ws) {
this.connect();
}
return () => {
this.subscriptions.delete(subscription.trialId);
// Don't auto-disconnect - keep global connection alive
};
}
sendMessage(message: OutgoingMessage) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
}
}
connect() {
if (
this.ws?.readyState === WebSocket.CONNECTING ||
this.ws?.readyState === WebSocket.OPEN
) {
return;
}
if (!this.sessionRef?.user) {
this.setState({ connectionError: "No session", isConnecting: false });
return;
}
this.setState({ isConnecting: true, connectionError: null });
const token = btoa(JSON.stringify({ userId: this.sessionRef.user.id }));
const wsPort = process.env.NEXT_PUBLIC_WS_PORT || "3001";
// Collect all trial IDs from subscriptions
const trialIds = Array.from(this.subscriptions.keys());
const trialIdParam = trialIds.length > 0 ? `&trialId=${trialIds[0]}` : "";
const url = `ws://${typeof window !== "undefined" ? window.location.hostname : "localhost"}:${wsPort}/api/websocket?token=${token}${trialIdParam}`;
try {
this.ws = new WebSocket(url);
this.ws.onopen = () => {
console.log("[GlobalWS] Connected");
this.setState({ isConnected: true, isConnecting: false });
this.attemptCount = 0;
this.startHeartbeat();
// Subscribe to all subscribed trials
this.subscriptions.forEach((sub) => {
this.ws?.send(
JSON.stringify({
type: "subscribe",
data: { trialId: sub.trialId },
}),
);
});
this.subscriptions.forEach((sub) => sub.onConnect?.());
};
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data) as WebSocketMessage;
this.setState({ lastMessage: message });
if (message.type === "connection_established") {
const data = (message as ConnectionEstablishedMessage).data;
const sub = this.subscriptions.get(data.trialId);
if (sub) {
sub.onMessage?.(message);
}
} else if (
message.type === "trial_event" ||
message.type === "trial_status"
) {
const data = (message as TrialEventMessage).data;
const event = data.event as { trialId?: string };
if (event?.trialId) {
const sub = this.subscriptions.get(event.trialId);
sub?.onMessage?.(message);
}
} else {
// Broadcast to all subscriptions
this.subscriptions.forEach((sub) => sub.onMessage?.(message));
}
} catch (error) {
console.error("[GlobalWS] Failed to parse message:", error);
}
};
this.ws.onclose = (event) => {
console.log("[GlobalWS] Disconnected:", event.code);
this.setState({ isConnected: false, isConnecting: false });
this.stopHeartbeat();
this.subscriptions.forEach((sub) => sub.onDisconnect?.());
// Auto-reconnect if not intentionally closed
if (event.code !== 1000 && this.subscriptions.size > 0) {
this.scheduleReconnect();
}
};
this.ws.onerror = (error) => {
console.error("[GlobalWS] Error:", error);
this.setState({
connectionError: "Connection error",
isConnecting: false,
});
this.subscriptions.forEach((sub) =>
sub.onError?.(new Event("ws_error")),
);
};
} catch (error) {
console.error("[GlobalWS] Failed to create:", error);
this.setState({
connectionError: "Failed to create connection",
isConnecting: false,
});
}
}
disconnect() {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
this.stopHeartbeat();
if (this.ws) {
this.ws.close(1000, "Manual disconnect");
this.ws = null;
}
this.setState({ isConnected: false, isConnecting: false });
}
private startHeartbeat() {
this.heartbeatInterval = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: "heartbeat", data: {} }));
}
}, 30000);
}
private stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
private scheduleReconnect() {
if (this.attemptCount >= this.maxAttempts) {
this.setState({ connectionError: "Max reconnection attempts reached" });
return;
}
const delay = Math.min(30000, 1000 * Math.pow(1.5, this.attemptCount));
this.attemptCount++;
console.log(
`[GlobalWS] Reconnecting in ${delay}ms (attempt ${this.attemptCount})`,
);
this.reconnectTimeout = setTimeout(() => {
if (this.subscriptions.size > 0) {
this.connect();
}
}, delay);
}
getState(): GlobalWSState {
return this.state;
}
addListener(listener: StateListener) {
this.stateListeners.add(listener);
return () => this.stateListeners.delete(listener);
}
}
const globalWS = new GlobalWebSocketManager();
export interface UseGlobalWebSocketOptions {
trialId: string;
onMessage?: (message: WebSocketMessage) => void;
onConnect?: () => void;
onDisconnect?: () => void;
onError?: (error: Event) => void;
}
export interface UseGlobalWebSocketReturn {
isConnected: boolean;
isConnecting: boolean;
connectionError: string | null;
@@ -119,333 +382,66 @@ export interface UseWebSocketReturn {
lastMessage: WebSocketMessage | null;
}
export function useWebSocket({
export function useGlobalWebSocket({
trialId,
onMessage,
onConnect,
onDisconnect,
onError,
reconnectAttempts = 5,
reconnectInterval = 3000,
heartbeatInterval = 30000,
}: UseWebSocketOptions): UseWebSocketReturn {
}: UseGlobalWebSocketOptions): UseGlobalWebSocketReturn {
const { data: session } = useSession();
const [isConnected, setIsConnected] = useState<boolean>(false);
const [isConnecting, setIsConnecting] = useState<boolean>(false);
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [hasAttemptedConnection, setHasAttemptedConnection] =
useState<boolean>(false);
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const heartbeatTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const attemptCountRef = useRef<number>(0);
const mountedRef = useRef<boolean>(true);
const connectionStableTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const onMessageRef = useRef(onMessage);
const onConnectRef = useRef(onConnect);
const onDisconnectRef = useRef(onDisconnect);
const onErrorRef = useRef(onError);
// Generate auth token (simplified - in production use proper JWT)
const getAuthToken = useCallback((): string | null => {
if (!session?.user) return null;
// In production, this would be a proper JWT token
return btoa(
JSON.stringify({ userId: session.user.id, timestamp: Date.now() }),
);
}, [session]);
onMessageRef.current = onMessage;
onConnectRef.current = onConnect;
onDisconnectRef.current = onDisconnect;
onErrorRef.current = onError;
const sendMessage = useCallback((message: OutgoingMessage): void => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message));
} else {
console.warn("WebSocket not connected, message not sent:", message);
}
}, []);
const sendHeartbeat = useCallback((): void => {
sendMessage({ type: "heartbeat", data: {} });
}, [sendMessage]);
const scheduleHeartbeat = useCallback((): void => {
if (heartbeatTimeoutRef.current) {
clearTimeout(heartbeatTimeoutRef.current);
}
heartbeatTimeoutRef.current = setTimeout(() => {
if (isConnected && mountedRef.current) {
sendHeartbeat();
scheduleHeartbeat();
}
}, heartbeatInterval);
}, [isConnected, sendHeartbeat, heartbeatInterval]);
const handleMessage = useCallback(
(event: MessageEvent<string>): void => {
try {
const message = JSON.parse(event.data) as WebSocketMessage;
setLastMessage(message);
// Handle system messages
switch (message.type) {
case "connection_established": {
console.log(
"WebSocket connection established:",
(message as ConnectionEstablishedMessage).data,
);
setIsConnected(true);
setIsConnecting(false);
setConnectionError(null);
attemptCountRef.current = 0;
scheduleHeartbeat();
onConnect?.();
break;
}
case "heartbeat_response":
// Heartbeat acknowledged, connection is alive
break;
case "error": {
console.error("WebSocket server error:", message);
const msg =
(message as ErrorMessage).data?.message ?? "Server error";
setConnectionError(msg);
onError?.(new Event("server_error"));
break;
}
default:
// Pass to user-defined message handler
onMessage?.(message);
break;
}
} catch (error) {
console.error("Error parsing WebSocket message:", error);
setConnectionError("Failed to parse message");
}
},
[onMessage, onConnect, onError, scheduleHeartbeat],
);
const handleClose = useCallback(
(event: CloseEvent): void => {
console.log("WebSocket connection closed:", event.code, event.reason);
setIsConnected(false);
setIsConnecting(false);
if (heartbeatTimeoutRef.current) {
clearTimeout(heartbeatTimeoutRef.current);
}
onDisconnect?.();
// Attempt reconnection if not manually closed and component is still mounted
// In development, don't aggressively reconnect to prevent UI flashing
if (
event.code !== 1000 &&
mountedRef.current &&
attemptCountRef.current < reconnectAttempts &&
process.env.NODE_ENV !== "development"
) {
attemptCountRef.current++;
const delay =
reconnectInterval * Math.pow(1.5, attemptCountRef.current - 1); // Exponential backoff
console.log(
`Attempting reconnection ${attemptCountRef.current}/${reconnectAttempts} in ${delay}ms`,
);
setConnectionError(
`Connection lost. Reconnecting... (${attemptCountRef.current}/${reconnectAttempts})`,
);
reconnectTimeoutRef.current = setTimeout(() => {
if (mountedRef.current) {
attemptCountRef.current = 0;
setIsConnecting(true);
setConnectionError(null);
}
}, delay);
} else if (attemptCountRef.current >= reconnectAttempts) {
setConnectionError("Failed to reconnect after maximum attempts");
} else if (
process.env.NODE_ENV === "development" &&
event.code !== 1000
) {
// In development, set a stable error message without reconnection attempts
setConnectionError("WebSocket unavailable - using polling mode");
}
},
[onDisconnect, reconnectAttempts, reconnectInterval],
);
const handleError = useCallback(
(event: Event): void => {
// In development, WebSocket failures are expected with Edge Runtime
if (process.env.NODE_ENV === "development") {
// Only set error state after the first failed attempt to prevent flashing
if (!hasAttemptedConnection) {
setHasAttemptedConnection(true);
// Debounce the error state to prevent UI flashing
if (connectionStableTimeoutRef.current) {
clearTimeout(connectionStableTimeoutRef.current);
}
connectionStableTimeoutRef.current = setTimeout(() => {
setConnectionError("WebSocket unavailable - using polling mode");
setIsConnecting(false);
}, 1000);
}
} else {
console.error("WebSocket error:", event);
setConnectionError("Connection error");
useEffect(() => {
const unsubscribe = globalWS.subscribe(session, {
trialId,
onMessage: (msg) => {
setLastMessage(msg);
onMessageRef.current?.(msg);
},
onConnect: () => {
setIsConnected(true);
setIsConnecting(false);
}
onError?.(event);
},
[onError, hasAttemptedConnection],
);
setConnectionError(null);
onConnectRef.current?.();
},
onDisconnect: () => {
setIsConnected(false);
onDisconnectRef.current?.();
},
onError: (err) => {
setConnectionError("Connection error");
onErrorRef.current?.(err);
},
});
const connectInternal = useCallback((): void => {
if (!session?.user || !trialId) {
if (!hasAttemptedConnection) {
setConnectionError("Missing authentication or trial ID");
setHasAttemptedConnection(true);
}
return;
}
return unsubscribe;
}, [trialId, session]);
if (
wsRef.current &&
(wsRef.current.readyState === WebSocket.CONNECTING ||
wsRef.current.readyState === WebSocket.OPEN)
) {
return; // Already connecting or connected
}
const token = getAuthToken();
if (!token) {
if (!hasAttemptedConnection) {
setConnectionError("Failed to generate auth token");
setHasAttemptedConnection(true);
}
return;
}
// Only show connecting state for the first attempt or if we've been stable
if (!hasAttemptedConnection || isConnected) {
setIsConnecting(true);
}
// Clear any pending error updates
if (connectionStableTimeoutRef.current) {
clearTimeout(connectionStableTimeoutRef.current);
}
setConnectionError(null);
try {
// Use appropriate WebSocket URL based on environment
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/api/websocket?trialId=${trialId}&token=${token}`;
wsRef.current = new WebSocket(wsUrl);
wsRef.current.onmessage = handleMessage;
wsRef.current.onclose = handleClose;
wsRef.current.onerror = handleError;
wsRef.current.onopen = () => {
console.log("WebSocket connection opened");
// Connection establishment is handled in handleMessage
};
} catch (error) {
console.error("Failed to create WebSocket connection:", error);
if (!hasAttemptedConnection) {
setConnectionError("Failed to create connection");
setHasAttemptedConnection(true);
}
setIsConnecting(false);
}
}, [
session,
trialId,
getAuthToken,
handleMessage,
handleClose,
handleError,
hasAttemptedConnection,
isConnected,
]);
const disconnect = useCallback((): void => {
mountedRef.current = false;
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (heartbeatTimeoutRef.current) {
clearTimeout(heartbeatTimeoutRef.current);
}
if (connectionStableTimeoutRef.current) {
clearTimeout(connectionStableTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close(1000, "Manual disconnect");
wsRef.current = null;
}
setIsConnected(false);
setIsConnecting(false);
setConnectionError(null);
setHasAttemptedConnection(false);
attemptCountRef.current = 0;
const sendMessage = useCallback((message: OutgoingMessage) => {
globalWS.sendMessage(message);
}, []);
const reconnect = useCallback((): void => {
disconnect();
mountedRef.current = true;
attemptCountRef.current = 0;
setHasAttemptedConnection(false);
setTimeout(() => {
if (mountedRef.current) {
void connectInternal();
}
}, 100); // Small delay to ensure cleanup
}, [disconnect, connectInternal]);
const disconnect = useCallback(() => {
globalWS.disconnect();
}, []);
// Effect to establish initial connection
useEffect(() => {
if (session?.user?.id && trialId) {
// In development, only attempt connection once to prevent flashing
if (process.env.NODE_ENV === "development" && hasAttemptedConnection) {
return;
}
// Trigger reconnection if timeout was set
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
void connectInternal();
} else {
void connectInternal();
}
}
return () => {
mountedRef.current = false;
disconnect();
};
}, [session?.user?.id, trialId, hasAttemptedConnection]);
// Cleanup on unmount
useEffect(() => {
return () => {
mountedRef.current = false;
if (connectionStableTimeoutRef.current) {
clearTimeout(connectionStableTimeoutRef.current);
}
disconnect();
};
}, [disconnect]);
const reconnect = useCallback(() => {
globalWS.connect();
}, []);
return {
isConnected,
@@ -458,115 +454,180 @@ export function useWebSocket({
};
}
// Hook for trial-specific WebSocket events
export function useTrialWebSocket(trialId: string) {
const [trialEvents, setTrialEvents] = useState<WebSocketMessage[]>([]);
const [currentTrialStatus, setCurrentTrialStatus] =
useState<TrialSnapshot | null>(null);
const [wizardActions, setWizardActions] = useState<WebSocketMessage[]>([]);
// Legacy alias
export const useWebSocket = useGlobalWebSocket;
const handleMessage = useCallback((message: WebSocketMessage): void => {
// Add to events log
setTrialEvents((prev) => [...prev, message].slice(-100)); // Keep last 100 events
// Trial-specific hook
export interface TrialEvent {
id: string;
trialId: string;
eventType: string;
data: Record<string, unknown> | null;
timestamp: Date;
createdBy?: string | null;
}
switch (message.type) {
case "trial_status": {
const data = (message as TrialStatusMessage).data;
setCurrentTrialStatus(data.trial);
break;
export interface TrialWebSocketState {
trialEvents: TrialEvent[];
currentTrialStatus: TrialSnapshot | null;
wizardActions: WebSocketMessage[];
}
export function useTrialWebSocket(
trialId: string,
options?: {
onTrialEvent?: (event: TrialEvent) => void;
onStatusChange?: (status: TrialSnapshot) => void;
initialEvents?: TrialEvent[];
initialStatus?: TrialSnapshot | null;
},
) {
const [state, setState] = useState<TrialWebSocketState>({
trialEvents: options?.initialEvents ?? [],
currentTrialStatus: options?.initialStatus ?? null,
wizardActions: [],
});
const handleMessage = useCallback(
(message: WebSocketMessage): void => {
switch (message.type) {
case "trial_status": {
const data = (message as TrialStatusMessage).data;
const status = data.trial as TrialSnapshot;
setState((prev) => ({
...prev,
currentTrialStatus: status,
}));
options?.onStatusChange?.(status);
break;
}
case "trial_events_snapshot": {
const data = (message as TrialEventsSnapshotMessage).data;
const events = (
data.events as Array<{
id: string;
trialId: string;
eventType: string;
data: Record<string, unknown> | null;
timestamp: Date | string;
createdBy?: string | null;
}>
).map((e) => ({
...e,
timestamp:
typeof e.timestamp === "string"
? new Date(e.timestamp)
: e.timestamp,
}));
setState((prev) => ({
...prev,
trialEvents: events,
}));
break;
}
case "trial_event": {
const data = (message as TrialEventMessage).data;
const event = data.event as {
id: string;
trialId: string;
eventType: string;
data: Record<string, unknown> | null;
timestamp: Date | string;
createdBy?: string | null;
};
const newEvent: TrialEvent = {
...event,
timestamp:
typeof event.timestamp === "string"
? new Date(event.timestamp)
: event.timestamp,
};
setState((prev) => ({
...prev,
trialEvents: [...prev.trialEvents, newEvent].slice(-500),
}));
options?.onTrialEvent?.(newEvent);
break;
}
case "trial_action_executed":
case "intervention_logged":
case "annotation_added":
case "step_changed": {
setState((prev) => ({
...prev,
wizardActions: [...prev.wizardActions, message].slice(-100),
}));
break;
}
case "pong":
break;
default:
if (process.env.NODE_ENV === "development") {
console.log(`[WS] Unknown message type: ${message.type}`);
}
}
},
[options],
);
case "trial_action_executed":
case "intervention_logged":
case "step_changed":
setWizardActions((prev) => [...prev, message].slice(-50)); // Keep last 50 actions
break;
case "step_changed":
// Handle step transitions (optional logging)
console.log("Step changed:", (message as StepChangedMessage).data);
break;
default:
// Handle other trial-specific messages
break;
}
}, []);
const webSocket = useWebSocket({
const webSocket = useGlobalWebSocket({
trialId,
onMessage: handleMessage,
onConnect: () => {
if (process.env.NODE_ENV === "development") {
console.log(`Connected to trial ${trialId} WebSocket`);
console.log(`[WS] Connected to trial ${trialId}`);
}
},
onDisconnect: () => {
if (process.env.NODE_ENV === "development") {
console.log(`Disconnected from trial ${trialId} WebSocket`);
console.log(`[WS] Disconnected from trial ${trialId}`);
}
},
onError: () => {
// Suppress noisy WebSocket errors in development
if (process.env.NODE_ENV !== "development") {
console.error(`Trial ${trialId} WebSocket connection failed`);
console.error(`[WS] Trial ${trialId} WebSocket connection failed`);
}
},
});
// Request trial status after connection is established
// Request initial data after connection is established
useEffect(() => {
if (webSocket.isConnected) {
webSocket.sendMessage({ type: "request_trial_status", data: {} });
webSocket.sendMessage({
type: "request_trial_events",
data: { limit: 500 },
});
}
}, [webSocket.isConnected, webSocket]);
}, [webSocket.isConnected]);
// Trial-specific actions
const executeTrialAction = useCallback(
(actionType: string, actionData: Record<string, unknown>): void => {
webSocket.sendMessage({
type: "trial_action",
data: {
actionType,
...actionData,
},
});
},
[webSocket],
);
// Helper to add an event locally (for optimistic updates)
const addLocalEvent = useCallback((event: TrialEvent) => {
setState((prev) => ({
...prev,
trialEvents: [...prev.trialEvents, event].slice(-500),
}));
}, []);
const logWizardIntervention = useCallback(
(interventionData: Record<string, unknown>): void => {
webSocket.sendMessage({
type: "wizard_intervention",
data: interventionData,
});
},
[webSocket],
);
const transitionStep = useCallback(
(stepData: {
from_step?: number;
to_step: number;
step_name?: string;
[k: string]: unknown;
}): void => {
webSocket.sendMessage({
type: "step_transition",
data: stepData,
});
},
[webSocket],
);
// Helper to update trial status locally
const updateLocalStatus = useCallback((status: TrialSnapshot) => {
setState((prev) => ({
...prev,
currentTrialStatus: status,
}));
}, []);
return {
...webSocket,
trialEvents,
currentTrialStatus,
wizardActions,
executeTrialAction,
logWizardIntervention,
transitionStep,
trialEvents: state.trialEvents,
currentTrialStatus: state.currentTrialStatus,
wizardActions: state.wizardActions,
addLocalEvent,
updateLocalStatus,
};
}