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

@@ -163,6 +163,11 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
const form = useForm<TrialFormData>({
resolver: zodResolver(trialSchema),
defaultValues: {
experimentId: "" as any,
participantId: "" as any,
scheduledAt: new Date(),
wizardId: undefined,
notes: "",
sessionNumber: 1,
},
});
@@ -347,7 +352,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
<FormField>
<Label htmlFor="experimentId">Experiment *</Label>
<Select
value={form.watch("experimentId")}
value={form.watch("experimentId") ?? ""}
onValueChange={(value) => form.setValue("experimentId", value)}
disabled={experimentsLoading || mode === "edit"}
>
@@ -387,7 +392,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
<FormField>
<Label htmlFor="participantId">Participant *</Label>
<Select
value={form.watch("participantId")}
value={form.watch("participantId") ?? ""}
onValueChange={(value) => form.setValue("participantId", value)}
disabled={participantsLoading || mode === "edit"}
>

View File

@@ -32,6 +32,7 @@ import { WebcamPanel } from "./panels/WebcamPanel";
import { TrialStatusBar } from "./panels/TrialStatusBar";
import { api } from "~/trpc/react";
import { useWizardRos } from "~/hooks/useWizardRos";
import { useTrialWebSocket, type TrialEvent } from "~/hooks/useWebSocket";
import { toast } from "sonner";
import { useTour } from "~/components/onboarding/TourProvider";
@@ -252,59 +253,65 @@ export const WizardInterface = React.memo(function WizardInterface({
[setAutonomousLifeRaw],
);
// Use polling for trial status updates (no trial WebSocket server exists)
const { data: pollingData } = api.trials.get.useQuery(
{ id: trial.id },
{
refetchInterval: trial.status === "in_progress" ? 5000 : 15000,
staleTime: 2000,
refetchOnWindowFocus: false,
// Trial WebSocket for real-time updates
const {
isConnected: wsConnected,
connectionError: wsError,
trialEvents: wsTrialEvents,
currentTrialStatus,
addLocalEvent,
} = useTrialWebSocket(trial.id, {
onStatusChange: (status) => {
// Update local trial state when WebSocket reports status changes
setTrial((prev) => ({
...prev,
status: status.status,
startedAt: status.startedAt
? new Date(status.startedAt)
: prev.startedAt,
completedAt: status.completedAt
? new Date(status.completedAt)
: prev.completedAt,
}));
},
);
// Poll for trial events
const { data: fetchedEvents } = api.trials.getEvents.useQuery(
{ trialId: trial.id, limit: 100 },
{
refetchInterval: 3000,
staleTime: 1000,
},
);
// Update local trial state from polling only if changed
useEffect(() => {
if (pollingData && JSON.stringify(pollingData) !== JSON.stringify(trial)) {
// Only update if specific fields we care about have changed to avoid
// unnecessary re-renders that might cause UI flashing
if (
pollingData.status !== trial.status ||
pollingData.startedAt?.getTime() !== trial.startedAt?.getTime() ||
pollingData.completedAt?.getTime() !== trial.completedAt?.getTime()
) {
setTrial((prev) => {
// Double check inside setter to be safe
if (
prev.status === pollingData.status &&
prev.startedAt?.getTime() === pollingData.startedAt?.getTime() &&
prev.completedAt?.getTime() === pollingData.completedAt?.getTime()
) {
return prev;
}
return {
...prev,
status: pollingData.status,
startedAt: pollingData.startedAt
? new Date(pollingData.startedAt)
: prev.startedAt,
completedAt: pollingData.completedAt
? new Date(pollingData.completedAt)
: prev.completedAt,
};
});
onTrialEvent: (event) => {
// Optionally show toast for new events
if (event.eventType === "trial_started") {
toast.info("Trial started");
} else if (event.eventType === "trial_completed") {
toast.info("Trial completed");
} else if (event.eventType === "trial_aborted") {
toast.warning("Trial aborted");
}
},
});
// Update trial state from WebSocket status
useEffect(() => {
if (currentTrialStatus) {
setTrial((prev) => {
if (
prev.status === currentTrialStatus.status &&
prev.startedAt?.getTime() ===
new Date(currentTrialStatus.startedAt ?? "").getTime() &&
prev.completedAt?.getTime() ===
new Date(currentTrialStatus.completedAt ?? "").getTime()
) {
return prev;
}
return {
...prev,
status: currentTrialStatus.status,
startedAt: currentTrialStatus.startedAt
? new Date(currentTrialStatus.startedAt)
: prev.startedAt,
completedAt: currentTrialStatus.completedAt
? new Date(currentTrialStatus.completedAt)
: prev.completedAt,
};
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pollingData]);
}, [currentTrialStatus]);
// Auto-start trial on mount if scheduled
useEffect(() => {
@@ -313,7 +320,7 @@ export const WizardInterface = React.memo(function WizardInterface({
}
}, []); // Run once on mount
// Trial events from robot actions
// Trial events from WebSocket (and initial load)
const trialEvents = useMemo<
Array<{
type: string;
@@ -322,8 +329,8 @@ export const WizardInterface = React.memo(function WizardInterface({
message?: string;
}>
>(() => {
return (fetchedEvents ?? [])
.map((event) => {
return (wsTrialEvents ?? [])
.map((event: TrialEvent) => {
let message: string | undefined;
const eventData = event.data as any;
@@ -364,7 +371,7 @@ export const WizardInterface = React.memo(function WizardInterface({
};
})
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); // Newest first
}, [fetchedEvents]);
}, [wsTrialEvents]);
// Transform experiment steps to component format
const steps: StepData[] = useMemo(