mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
feat(analytics): refine timeline visualization and add print support
This commit is contained in:
@@ -11,7 +11,6 @@ import {
|
||||
EntityForm,
|
||||
FormField,
|
||||
FormSection,
|
||||
NextSteps,
|
||||
Tips,
|
||||
} from "~/components/ui/entity-form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
@@ -27,10 +26,113 @@ import { Textarea } from "~/components/ui/textarea";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
import { Calendar as CalendarIcon, Clock } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Calendar } from "~/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/ui/popover";
|
||||
import { Controller } from "react-hook-form";
|
||||
|
||||
// Custom DatePickerTime component based on user request
|
||||
function DateTimePicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: Date | undefined;
|
||||
onChange: (date: Date | undefined) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Parse time from value or default
|
||||
const timeValue = value ? format(value, "HH:mm") : "12:00";
|
||||
|
||||
const onDateSelect = (newDate: Date | undefined) => {
|
||||
if (!newDate) {
|
||||
onChange(undefined);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Preserve existing time or use default
|
||||
const [hours, minutes] = timeValue.split(":").map(Number);
|
||||
const updatedDate = new Date(newDate);
|
||||
updatedDate.setHours(hours || 0);
|
||||
updatedDate.setMinutes(minutes || 0);
|
||||
updatedDate.setSeconds(0);
|
||||
|
||||
onChange(updatedDate);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const onTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newTime = e.target.value;
|
||||
if (!value) return; // Can't set time without date
|
||||
|
||||
const [hours, minutes] = newTime.split(":").map(Number);
|
||||
const updatedDate = new Date(value);
|
||||
updatedDate.setHours(hours || 0);
|
||||
updatedDate.setMinutes(minutes || 0);
|
||||
updatedDate.setSeconds(0);
|
||||
|
||||
onChange(updatedDate);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="date-picker" className="text-xs">Date</Label>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
id="date-picker"
|
||||
className={cn(
|
||||
"w-[240px] justify-start text-left font-normal",
|
||||
!value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{value ? format(value, "PPP") : <span>Pick a date</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={value}
|
||||
onSelect={onDateSelect}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="time-picker" className="text-xs">Time</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="time-picker"
|
||||
type="time"
|
||||
value={timeValue}
|
||||
onChange={onTimeChange}
|
||||
disabled={!value}
|
||||
className="w-[120px]"
|
||||
/>
|
||||
<Clock className="absolute right-3 top-2.5 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const trialSchema = z.object({
|
||||
experimentId: z.string().uuid("Please select an experiment"),
|
||||
participantId: z.string().uuid("Please select a participant"),
|
||||
scheduledAt: z.string().min(1, "Please select a date and time"),
|
||||
scheduledAt: z.date(),
|
||||
wizardId: z.string().uuid().optional(),
|
||||
notes: z.string().max(1000, "Notes cannot exceed 1000 characters").optional(),
|
||||
sessionNumber: z
|
||||
@@ -52,7 +154,6 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
const contextStudyId = studyId ?? selectedStudyId;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const form = useForm<TrialFormData>({
|
||||
@@ -90,6 +191,22 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
const { data: usersData, isLoading: usersLoading } =
|
||||
api.users.getWizards.useQuery();
|
||||
|
||||
// Auto-increment session number
|
||||
const selectedParticipantId = form.watch("participantId");
|
||||
const { data: latestSession } = api.trials.getLatestSession.useQuery(
|
||||
{ participantId: selectedParticipantId },
|
||||
{
|
||||
enabled: !!selectedParticipantId && mode === "create",
|
||||
refetchOnWindowFocus: false
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (latestSession !== undefined && mode === "create") {
|
||||
form.setValue("sessionNumber", latestSession + 1);
|
||||
}
|
||||
}, [latestSession, mode, form]);
|
||||
|
||||
// Set breadcrumbs
|
||||
const breadcrumbs = [
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
@@ -133,9 +250,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
form.reset({
|
||||
experimentId: trial.experimentId,
|
||||
participantId: trial?.participantId ?? "",
|
||||
scheduledAt: trial.scheduledAt
|
||||
? new Date(trial.scheduledAt).toISOString().slice(0, 16)
|
||||
: "",
|
||||
scheduledAt: trial.scheduledAt ? new Date(trial.scheduledAt) : undefined,
|
||||
wizardId: trial.wizardId ?? undefined,
|
||||
notes: trial.notes ?? "",
|
||||
sessionNumber: trial.sessionNumber ?? 1,
|
||||
@@ -153,24 +268,26 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
|
||||
try {
|
||||
if (mode === "create") {
|
||||
const newTrial = await createTrialMutation.mutateAsync({
|
||||
await createTrialMutation.mutateAsync({
|
||||
experimentId: data.experimentId,
|
||||
participantId: data.participantId,
|
||||
scheduledAt: new Date(data.scheduledAt),
|
||||
scheduledAt: data.scheduledAt,
|
||||
wizardId: data.wizardId,
|
||||
sessionNumber: data.sessionNumber ?? 1,
|
||||
notes: data.notes ?? undefined,
|
||||
});
|
||||
router.push(`/studies/${contextStudyId}/trials/${newTrial!.id}`);
|
||||
// Redirect to trials table instead of detail page
|
||||
router.push(`/studies/${contextStudyId}/trials`);
|
||||
} else {
|
||||
const updatedTrial = await updateTrialMutation.mutateAsync({
|
||||
await updateTrialMutation.mutateAsync({
|
||||
id: trialId!,
|
||||
scheduledAt: new Date(data.scheduledAt),
|
||||
scheduledAt: data.scheduledAt,
|
||||
wizardId: data.wizardId,
|
||||
sessionNumber: data.sessionNumber ?? 1,
|
||||
notes: data.notes ?? undefined,
|
||||
});
|
||||
router.push(`/studies/${contextStudyId}/trials/${updatedTrial!.id}`);
|
||||
// Redirect to trials table on update too
|
||||
router.push(`/studies/${contextStudyId}/trials`);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(
|
||||
@@ -181,9 +298,6 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// Delete handler (trials cannot be deleted in this version)
|
||||
const onDelete = undefined;
|
||||
|
||||
// Loading state for edit mode
|
||||
if (mode === "edit" && isLoading) {
|
||||
return <div>Loading trial...</div>;
|
||||
@@ -194,233 +308,6 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
return <div>Error loading trial: {fetchError.message}</div>;
|
||||
}
|
||||
|
||||
// Form fields
|
||||
const formFields = (
|
||||
<>
|
||||
<FormSection
|
||||
title="Trial Setup"
|
||||
description="Configure the basic details for this experimental trial."
|
||||
>
|
||||
<FormField>
|
||||
<Label htmlFor="experimentId">Experiment *</Label>
|
||||
<Select
|
||||
value={form.watch("experimentId")}
|
||||
onValueChange={(value) => form.setValue("experimentId", value)}
|
||||
disabled={experimentsLoading || mode === "edit"}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={
|
||||
form.formState.errors.experimentId ? "border-red-500" : ""
|
||||
}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
experimentsLoading
|
||||
? "Loading experiments..."
|
||||
: "Select an experiment"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{experimentsData?.map((experiment) => (
|
||||
<SelectItem key={experiment.id} value={experiment.id}>
|
||||
{experiment.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.formState.errors.experimentId && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.experimentId.message}
|
||||
</p>
|
||||
)}
|
||||
{mode === "edit" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Experiment cannot be changed after creation
|
||||
</p>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="participantId">Participant *</Label>
|
||||
<Select
|
||||
value={form.watch("participantId")}
|
||||
onValueChange={(value) => form.setValue("participantId", value)}
|
||||
disabled={participantsLoading || mode === "edit"}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={
|
||||
form.formState.errors.participantId ? "border-red-500" : ""
|
||||
}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
participantsLoading
|
||||
? "Loading participants..."
|
||||
: "Select a participant"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{participantsData?.participants?.map((participant) => (
|
||||
<SelectItem key={participant.id} value={participant.id}>
|
||||
{participant.name ?? participant.participantCode} (
|
||||
{participant.participantCode})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.formState.errors.participantId && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.participantId.message}
|
||||
</p>
|
||||
)}
|
||||
{mode === "edit" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Participant cannot be changed after creation
|
||||
</p>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="scheduledAt">Scheduled Date & Time *</Label>
|
||||
<Input
|
||||
id="scheduledAt"
|
||||
type="datetime-local"
|
||||
{...form.register("scheduledAt")}
|
||||
className={
|
||||
form.formState.errors.scheduledAt ? "border-red-500" : ""
|
||||
}
|
||||
/>
|
||||
{form.formState.errors.scheduledAt && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.scheduledAt.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
When should this trial be conducted?
|
||||
</p>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="sessionNumber">Session Number</Label>
|
||||
<Input
|
||||
id="sessionNumber"
|
||||
type="number"
|
||||
min="1"
|
||||
{...form.register("sessionNumber", { valueAsNumber: true })}
|
||||
placeholder="1"
|
||||
className={
|
||||
form.formState.errors.sessionNumber ? "border-red-500" : ""
|
||||
}
|
||||
/>
|
||||
{form.formState.errors.sessionNumber && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.sessionNumber.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Session number for this participant (for multi-session studies)
|
||||
</p>
|
||||
</FormField>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
title="Assignment & Notes"
|
||||
description="Optional wizard assignment and trial-specific notes."
|
||||
>
|
||||
<FormField>
|
||||
<Label htmlFor="wizardId">Assigned Wizard</Label>
|
||||
<Select
|
||||
value={form.watch("wizardId") ?? "none"}
|
||||
onValueChange={(value) =>
|
||||
form.setValue("wizardId", value === "none" ? undefined : value)
|
||||
}
|
||||
disabled={usersLoading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
usersLoading
|
||||
? "Loading wizards..."
|
||||
: "Select a wizard (optional)"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No wizard assigned</SelectItem>
|
||||
{usersData?.map(
|
||||
(user: { id: string; name: string; email: string }) => (
|
||||
<SelectItem key={user.id} value={user.id}>
|
||||
{user.name} ({user.email})
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Optional: Assign a specific wizard to operate this trial
|
||||
</p>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="notes">Trial Notes</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
{...form.register("notes")}
|
||||
placeholder="Special instructions, conditions, or notes for this trial..."
|
||||
rows={3}
|
||||
className={form.formState.errors.notes ? "border-red-500" : ""}
|
||||
/>
|
||||
{form.formState.errors.notes && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.notes.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Optional: Notes about special conditions, instructions, or context
|
||||
for this trial
|
||||
</p>
|
||||
</FormField>
|
||||
</FormSection>
|
||||
</>
|
||||
);
|
||||
|
||||
// Sidebar content
|
||||
const sidebar = (
|
||||
<>
|
||||
<NextSteps
|
||||
steps={[
|
||||
{
|
||||
title: "Execute Trial",
|
||||
description: "Use the wizard interface to run the trial",
|
||||
completed: mode === "edit",
|
||||
},
|
||||
{
|
||||
title: "Monitor Progress",
|
||||
description: "Track trial execution and data collection",
|
||||
},
|
||||
{
|
||||
title: "Review Data",
|
||||
description: "Analyze collected trial data and results",
|
||||
},
|
||||
{
|
||||
title: "Generate Reports",
|
||||
description: "Export data and create analysis reports",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Tips
|
||||
tips={[
|
||||
"Schedule ahead: Allow sufficient time between trials for setup and data review.",
|
||||
"Assign wizards: Pre-assign experienced wizards to complex trials.",
|
||||
"Document conditions: Use notes to record any special circumstances or variations.",
|
||||
"Test connectivity: Verify robot and system connections before scheduled trials.",
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<EntityForm
|
||||
mode={mode}
|
||||
@@ -443,14 +330,196 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
onSubmit={onSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
error={error}
|
||||
onDelete={
|
||||
mode === "edit" && trial?.status === "scheduled" ? onDelete : undefined
|
||||
}
|
||||
isDeleting={isDeleting}
|
||||
sidebar={sidebar}
|
||||
sidebar={undefined}
|
||||
submitText={mode === "create" ? "Schedule Trial" : "Save Changes"}
|
||||
layout="full-width"
|
||||
>
|
||||
{formFields}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Left Column: Main Info (Spans 2) */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<FormField>
|
||||
<Label htmlFor="experimentId">Experiment *</Label>
|
||||
<Select
|
||||
value={form.watch("experimentId")}
|
||||
onValueChange={(value) => form.setValue("experimentId", value)}
|
||||
disabled={experimentsLoading || mode === "edit"}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={
|
||||
form.formState.errors.experimentId ? "border-red-500" : ""
|
||||
}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
experimentsLoading
|
||||
? "Loading experiments..."
|
||||
: "Select an experiment"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{experimentsData?.map((experiment) => (
|
||||
<SelectItem key={experiment.id} value={experiment.id}>
|
||||
{experiment.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.formState.errors.experimentId && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.experimentId.message}
|
||||
</p>
|
||||
)}
|
||||
{mode === "edit" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Experiment cannot be changed after creation
|
||||
</p>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="participantId">Participant *</Label>
|
||||
<Select
|
||||
value={form.watch("participantId")}
|
||||
onValueChange={(value) => form.setValue("participantId", value)}
|
||||
disabled={participantsLoading || mode === "edit"}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={
|
||||
form.formState.errors.participantId ? "border-red-500" : ""
|
||||
}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
participantsLoading
|
||||
? "Loading participants..."
|
||||
: "Select a participant"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{participantsData?.participants?.map((participant) => (
|
||||
<SelectItem key={participant.id} value={participant.id}>
|
||||
{participant.name ?? participant.participantCode} (
|
||||
{participant.participantCode})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.formState.errors.participantId && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.participantId.message}
|
||||
</p>
|
||||
)}
|
||||
{mode === "edit" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Participant cannot be changed after creation
|
||||
</p>
|
||||
)}
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<FormField>
|
||||
<Label htmlFor="scheduledAt">Scheduled Date & Time *</Label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="scheduledAt"
|
||||
render={({ field }) => (
|
||||
<DateTimePicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{form.formState.errors.scheduledAt && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.scheduledAt.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
When should this trial be conducted?
|
||||
</p>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="sessionNumber">Session Number</Label>
|
||||
<Input
|
||||
id="sessionNumber"
|
||||
type="number"
|
||||
min="1"
|
||||
{...form.register("sessionNumber", { valueAsNumber: true })}
|
||||
placeholder="1"
|
||||
className={
|
||||
form.formState.errors.sessionNumber ? "border-red-500" : ""
|
||||
}
|
||||
/>
|
||||
{form.formState.errors.sessionNumber && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.sessionNumber.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Auto-incremented based on participant history
|
||||
</p>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Assignment & Notes (Spans 1) */}
|
||||
<div className="space-y-6">
|
||||
<FormField>
|
||||
<Label htmlFor="wizardId">Assigned Wizard</Label>
|
||||
<Select
|
||||
value={form.watch("wizardId") ?? "none"}
|
||||
onValueChange={(value) =>
|
||||
form.setValue("wizardId", value === "none" ? undefined : value)
|
||||
}
|
||||
disabled={usersLoading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
usersLoading
|
||||
? "Loading wizards..."
|
||||
: "Select a wizard (optional)"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No wizard assigned</SelectItem>
|
||||
{usersData?.map(
|
||||
(user: { id: string; name: string; email: string }) => (
|
||||
<SelectItem key={user.id} value={user.id}>
|
||||
{user.name} ({user.email})
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Who will operate the robot?
|
||||
</p>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="notes">Notes</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
{...form.register("notes")}
|
||||
placeholder="Special instructions..."
|
||||
rows={5}
|
||||
className={form.formState.errors.notes ? "border-red-500" : ""}
|
||||
/>
|
||||
{form.formState.errors.notes && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.notes.message}
|
||||
</p>
|
||||
)}
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</EntityForm>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { ArrowUpDown, ChevronDown, MoreHorizontal, Play, Gamepad2, LineChart, Ban } from "lucide-react";
|
||||
import { ArrowUpDown, ChevronDown, MoreHorizontal, Play, Gamepad2, LineChart, Ban, Printer } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
@@ -108,10 +108,25 @@ export const columns: ColumnDef<Trial>[] = [
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const sessionNumber = row.getValue("sessionNumber");
|
||||
const status = row.original.status;
|
||||
const trialId = row.original.id;
|
||||
const studyId = row.original.studyId;
|
||||
|
||||
let href = `/studies/${studyId}/trials/${trialId}`; // Fallback
|
||||
if (status === "scheduled" || status === "in_progress") {
|
||||
href = `/studies/${studyId}/trials/${trialId}/wizard`;
|
||||
} else if (status === "completed") {
|
||||
href = `/studies/${studyId}/trials/${trialId}/analysis`;
|
||||
} else {
|
||||
// for aborted/failed, maybe still link to detail or nowhere?
|
||||
// Let's keep detail for now as a fallback for metadata
|
||||
href = `/studies/${studyId}/trials/${trialId}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="font-mono text-sm">
|
||||
<Link
|
||||
href={`/studies/${row.original.studyId}/trials/${row.original.id}`}
|
||||
href={href}
|
||||
className="hover:underline"
|
||||
>
|
||||
#{Number(sessionNumber)}
|
||||
@@ -343,63 +358,52 @@ function ActionsCell({ row }: { row: { original: Trial } }) {
|
||||
const trial = row.original;
|
||||
// ActionsCell is a component rendered by the table.
|
||||
|
||||
// importing useRouter is fine.
|
||||
|
||||
const utils = api.useUtils();
|
||||
const duplicateMutation = api.trials.duplicate.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.trials.list.invalidate();
|
||||
// toast.success("Trial duplicated"); // We need toast
|
||||
},
|
||||
});
|
||||
|
||||
if (!trial?.id) {
|
||||
return <span className="text-muted-foreground text-sm">No actions</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
{trial.status === "scheduled" && (
|
||||
<Button size="sm" asChild>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
|
||||
<Play className="mr-1.5 h-3.5 w-3.5" />
|
||||
Start
|
||||
</Link>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
{trial.status === "scheduled" && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start Trial
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{trial.status === "in_progress" && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
|
||||
<Gamepad2 className="mr-2 h-4 w-4" />
|
||||
Control Trial
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{trial.status === "completed" && (
|
||||
<DropdownMenuItem asChild>
|
||||
)}
|
||||
{trial.status === "in_progress" && (
|
||||
<Button size="sm" variant="secondary" asChild>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
|
||||
<Gamepad2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
Control
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{trial.status === "completed" && (
|
||||
<>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/analysis`}>
|
||||
<LineChart className="mr-2 h-4 w-4" />
|
||||
Analysis
|
||||
<LineChart className="mr-1.5 h-3.5 w-3.5" />
|
||||
View
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{(trial.status === "scheduled" || trial.status === "failed") && (
|
||||
<DropdownMenuItem className="text-red-600">
|
||||
<Ban className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
{/* We link to the analysis page with a query param to trigger print/export */}
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/analysis?export=true`}>
|
||||
<Printer className="mr-1.5 h-3.5 w-3.5" />
|
||||
Export
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(trial.status === "scheduled" || trial.status === "failed") && (
|
||||
<Button size="sm" variant="ghost" className="h-8 w-8 p-0 text-muted-foreground hover:text-red-600">
|
||||
<Ban className="h-4 w-4" />
|
||||
<span className="sr-only">Cancel</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ export function EventsDataTable({ data, startTime }: EventsDataTableProps) {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Events</SelectItem>
|
||||
<SelectItem value="action_executed">Actions</SelectItem>
|
||||
<SelectItem value="action_skipped">Skipped Actions</SelectItem>
|
||||
<SelectItem value="intervention">Interventions</SelectItem>
|
||||
<SelectItem value="robot">Robot Actions</SelectItem>
|
||||
<SelectItem value="step">Step Changes</SelectItem>
|
||||
|
||||
@@ -73,8 +73,7 @@ export function EventTimeline() {
|
||||
|
||||
const currentProgress = (currentTime * 1000 / effectiveDuration) * 100;
|
||||
|
||||
// Generate ticks for "number line" look
|
||||
// We want a major tick every ~10% or meaningful time interval
|
||||
// Generate ticks
|
||||
const ticks = useMemo(() => {
|
||||
const count = 10;
|
||||
return Array.from({ length: count + 1 }).map((_, i) => ({
|
||||
@@ -84,106 +83,75 @@ export function EventTimeline() {
|
||||
}, [effectiveDuration]);
|
||||
|
||||
const getEventIcon = (type: string) => {
|
||||
if (type.includes("intervention") || type.includes("wizard")) return <User className="h-3 w-3" />;
|
||||
if (type.includes("robot") || type.includes("action")) return <Bot className="h-3 w-3" />;
|
||||
if (type.includes("completed")) return <CheckCircle className="h-3 w-3" />;
|
||||
if (type.includes("start")) return <Flag className="h-3 w-3" />;
|
||||
if (type.includes("note")) return <MessageSquare className="h-3 w-3" />;
|
||||
if (type.includes("error")) return <AlertTriangle className="h-3 w-3" />;
|
||||
return <Activity className="h-3 w-3" />;
|
||||
if (type.includes("intervention") || type.includes("wizard")) return <User className="h-4 w-4" />;
|
||||
if (type.includes("robot") || type.includes("action")) return <Bot className="h-4 w-4" />;
|
||||
if (type.includes("completed")) return <CheckCircle className="h-4 w-4" />;
|
||||
if (type.includes("start")) return <Flag className="h-4 w-4" />;
|
||||
if (type.includes("note")) return <MessageSquare className="h-4 w-4" />;
|
||||
if (type.includes("error")) return <AlertTriangle className="h-4 w-4" />;
|
||||
return <Activity className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
const getEventColor = (type: string) => {
|
||||
if (type.includes("intervention") || type.includes("wizard")) return "text-orange-500 border-orange-200 bg-orange-50";
|
||||
if (type.includes("robot") || type.includes("action")) return "text-purple-500 border-purple-200 bg-purple-50";
|
||||
if (type.includes("completed")) return "text-green-500 border-green-200 bg-green-50";
|
||||
if (type.includes("start")) return "text-blue-500 border-blue-200 bg-blue-50";
|
||||
if (type.includes("error")) return "text-red-500 border-red-200 bg-red-50";
|
||||
return "text-slate-500 border-slate-200 bg-slate-50";
|
||||
if (type.includes("intervention") || type.includes("wizard")) return "bg-orange-100 text-orange-600 border-orange-200";
|
||||
if (type.includes("robot") || type.includes("action")) return "bg-purple-100 text-purple-600 border-purple-200";
|
||||
if (type.includes("completed")) return "bg-green-100 text-green-600 border-green-200";
|
||||
if (type.includes("start")) return "bg-blue-100 text-blue-600 border-blue-200";
|
||||
if (type.includes("error")) return "bg-red-100 text-red-600 border-red-200";
|
||||
return "bg-slate-100 text-slate-600 border-slate-200";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col select-none py-2">
|
||||
<TooltipProvider>
|
||||
{/* Timeline Track Container */}
|
||||
<div className="w-full h-28 flex flex-col justify-center px-8 select-none">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
{/* Main Interactive Area */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full flex-1 min-h-[80px] group cursor-crosshair border-b border-border/50"
|
||||
className="relative w-full h-16 flex items-center cursor-pointer group"
|
||||
onClick={handleSeek}
|
||||
>
|
||||
{/* Background Grid/Ticks */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{/* Major Ticks */}
|
||||
{ticks.map((tick, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute top-0 bottom-0 border-l border-border/30 flex flex-col justify-end"
|
||||
style={{ left: `${tick.pct}%` }}
|
||||
>
|
||||
<span className="text-[10px] font-mono text-muted-foreground -ml-3 mb-1 bg-background/80 px-1 rounded">
|
||||
{tick.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* The Timeline Line (Horizontal) */}
|
||||
<div className="absolute left-0 right-0 h-0.5 top-1/2 -mt-px bg-border group-hover:bg-border/80 transition-colors" />
|
||||
|
||||
{/* Central Axis Line */}
|
||||
<div className="absolute top-1/2 left-0 right-0 h-px bg-border z-0" />
|
||||
|
||||
{/* Progress Fill (Subtle) */}
|
||||
{/* Progress Fill */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 left-0 bg-primary/5 z-0 pointer-events-none"
|
||||
style={{ width: `${currentProgress}%` }}
|
||||
className="absolute left-0 h-0.5 bg-primary/30 pointer-events-none"
|
||||
style={{ width: `${currentProgress}%`, top: '50%', marginTop: '-1px' }}
|
||||
/>
|
||||
|
||||
{/* Playhead */}
|
||||
{/* Playhead (Scanner) */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-px bg-red-500 z-30 pointer-events-none transition-all duration-75"
|
||||
style={{ left: `${currentProgress}%` }}
|
||||
className="absolute h-16 w-px bg-red-500 z-30 pointer-events-none transition-all duration-75"
|
||||
style={{ left: `${currentProgress}%`, top: '50%', transform: 'translateY(-50%)' }}
|
||||
>
|
||||
<div className="absolute -top-1 -ml-1.5 p-0.5 bg-red-500 rounded text-[8px] font-bold text-white w-3 h-3 flex items-center justify-center">
|
||||
▼
|
||||
</div>
|
||||
{/* Knob */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-3 h-3 bg-red-500 rounded-full shadow border border-white" />
|
||||
</div>
|
||||
|
||||
{/* Events "Lollipops" */}
|
||||
{/* Events (Avatars/Dots) */}
|
||||
{sortedEvents.map((event, i) => {
|
||||
const pct = getPercentage(new Date(event.timestamp).getTime());
|
||||
const isTop = i % 2 === 0; // Stagger events top/bottom
|
||||
|
||||
return (
|
||||
<Tooltip key={i}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="absolute z-20 flex flex-col items-center group/event"
|
||||
style={{
|
||||
left: `${pct}%`,
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
height: '100%'
|
||||
}}
|
||||
className="absolute z-20 top-1/2 left-0 transform -translate-x-1/2 -translate-y-1/2 flex flex-col items-center group/event"
|
||||
style={{ left: `${pct}%` }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
seekTo((new Date(event.timestamp).getTime() - startTime) / 1000);
|
||||
}}
|
||||
>
|
||||
{/* The Stem */}
|
||||
<div className={cn(
|
||||
"w-px transition-all duration-200 bg-border group-hover/event:bg-primary group-hover/event:h-full",
|
||||
isTop ? "h-8 mb-auto" : "h-8 mt-auto"
|
||||
)} />
|
||||
|
||||
{/* The Node */}
|
||||
<div className={cn(
|
||||
"absolute w-6 h-6 rounded-full border shadow-sm flex items-center justify-center transition-transform hover:scale-110 cursor-pointer bg-background z-10",
|
||||
getEventColor(event.eventType),
|
||||
isTop ? "-top-2" : "-bottom-2"
|
||||
"flex h-8 w-8 items-center justify-center rounded-full border shadow-sm transition-transform hover:scale-125 hover:z-50 bg-background relative z-20",
|
||||
getEventColor(event.eventType)
|
||||
)}>
|
||||
{getEventIcon(event.eventType)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={isTop ? "top" : "bottom"}>
|
||||
<TooltipContent side="top">
|
||||
<div className="text-xs font-semibold uppercase tracking-wider mb-0.5">{event.eventType.replace(/_/g, " ")}</div>
|
||||
<div className="text-[10px] font-mono opacity-70 mb-1">
|
||||
{new Date(event.timestamp).toLocaleTimeString()}
|
||||
@@ -197,9 +165,21 @@ export function EventTimeline() {
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Ticks (Below) */}
|
||||
{ticks.map((tick, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute top-10 text-[10px] font-mono text-muted-foreground transform -translate-x-1/2 pointer-events-none flex flex-col items-center"
|
||||
style={{ left: `${tick.pct}%` }}
|
||||
>
|
||||
{/* Tick Mark */}
|
||||
<div className="w-px h-2 bg-border mb-1" />
|
||||
{tick.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { LineChart, BarChart, Clock, Database, FileText, AlertTriangle, CheckCircle, VideoOff, Info, Bot, Activity, ArrowLeft } from "lucide-react";
|
||||
import { LineChart, BarChart, Printer, Clock, Database, FileText, AlertTriangle, CheckCircle, VideoOff, Info, Bot, Activity, ArrowLeft } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { PlaybackProvider } from "../playback/PlaybackContext";
|
||||
import { PlaybackPlayer } from "../playback/PlaybackPlayer";
|
||||
import { EventTimeline } from "../playback/EventTimeline";
|
||||
@@ -25,7 +27,7 @@ interface TrialAnalysisViewProps {
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
duration: number | null;
|
||||
experiment: { name: string };
|
||||
experiment: { name: string; studyId: string };
|
||||
participant: { participantCode: string };
|
||||
eventCount?: number;
|
||||
mediaCount?: number;
|
||||
@@ -41,6 +43,17 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
||||
limit: 1000
|
||||
});
|
||||
|
||||
// Auto-print effect
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
if (searchParams.get('export') === 'true') {
|
||||
// Small delay to ensure rendering
|
||||
setTimeout(() => {
|
||||
window.print();
|
||||
}, 1000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const videoMedia = trial.media?.find(m => m.contentType.startsWith("video/"));
|
||||
const videoUrl = videoMedia?.url;
|
||||
|
||||
@@ -51,50 +64,130 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
||||
|
||||
return (
|
||||
<PlaybackProvider events={events} startTime={trial.startedAt ?? undefined}>
|
||||
<div className="flex h-full flex-col gap-4 p-4 text-sm">
|
||||
<div id="trial-analysis-content" className="flex h-full flex-col gap-4 p-4 text-sm">
|
||||
{/* Header Context */}
|
||||
<div className="flex items-center justify-between pb-2 border-b">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" asChild className="-ml-2">
|
||||
<Link href={backHref}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 ml-1" onClick={() => {
|
||||
// Dispatch custom event since useTour isn't directly available in this specific context yet
|
||||
// or better yet, assume we can import useTour if valid context, but here let's try direct button if applicable.
|
||||
// Actually, TrialAnalysisView is a child of page, we need useTour context.
|
||||
// Checking imports... TrialAnalysisView doesn't have useTour.
|
||||
// We should probably just dispatch an event or rely on the parent.
|
||||
// Let's assume we can add useTour hook support here.
|
||||
document.dispatchEvent(new CustomEvent('hristudio-start-tour', { detail: 'analytics' }));
|
||||
}}>
|
||||
<Info className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-lg font-semibold leading-none tracking-tight">
|
||||
{trial.experiment.name}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 text-muted-foreground mt-1">
|
||||
<span className="font-mono">{trial.participant.participantCode}</span>
|
||||
<span>•</span>
|
||||
<span>Session {trial.id.slice(0, 4)}</span>
|
||||
</div>
|
||||
<PageHeader
|
||||
title={trial.experiment.name}
|
||||
description={`Session ${trial.id.slice(0, 8)} • ${trial.startedAt?.toLocaleDateString() ?? 'Unknown Date'} ${trial.startedAt?.toLocaleTimeString() ?? ''}`}
|
||||
badges={[
|
||||
{
|
||||
label: trial.status.toUpperCase(),
|
||||
variant: trial.status === 'completed' ? 'default' : 'secondary',
|
||||
className: trial.status === 'completed' ? 'bg-green-500 hover:bg-green-600' : ''
|
||||
}
|
||||
]}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<style jsx global>{`
|
||||
@media print {
|
||||
@page {
|
||||
size: auto;
|
||||
margin: 15mm;
|
||||
}
|
||||
body {
|
||||
background: white;
|
||||
color: black;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
/* Hide everything by default */
|
||||
body * {
|
||||
visibility: hidden;
|
||||
}
|
||||
/* Show only our content */
|
||||
#trial-analysis-content, #trial-analysis-content * {
|
||||
visibility: visible;
|
||||
}
|
||||
#trial-analysis-content {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* Hide specific non-printable elements */
|
||||
#tour-trial-video,
|
||||
button,
|
||||
.no-print,
|
||||
[role="dialog"],
|
||||
header,
|
||||
nav {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Adjust Metrics for Print */
|
||||
#tour-trial-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
#tour-trial-metrics .rounded-xl {
|
||||
border: 1px solid #ddd;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Expand Timeline */
|
||||
.h-28 {
|
||||
height: 120px !important;
|
||||
page-break-inside: avoid;
|
||||
border-bottom: 1px solid #eee;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Remove Panel Resizing constraints */
|
||||
[data-panel-group-direction="vertical"] {
|
||||
flex-direction: column !important;
|
||||
display: block !important;
|
||||
height: auto !important;
|
||||
}
|
||||
[data-panel] {
|
||||
flex: none !important;
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
[data-panel-resize-handle] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Table Styles: Clean & Full Width */
|
||||
#tour-trial-events {
|
||||
display: block !important;
|
||||
border: none !important;
|
||||
height: auto !important;
|
||||
}
|
||||
#tour-trial-events [data-radix-scroll-area-viewport] {
|
||||
overflow: visible !important;
|
||||
height: auto !important;
|
||||
}
|
||||
/* Hide "Filter" input wrapper if visible */
|
||||
#tour-trial-events .border-b {
|
||||
border-bottom: 2px solid #000 !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => window.print()}
|
||||
>
|
||||
<Printer className="h-4 w-4" />
|
||||
Export Report
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-muted-foreground bg-muted/30 px-3 py-1 rounded-full border">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-mono">
|
||||
{trial.startedAt?.toLocaleDateString()} {trial.startedAt?.toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Metrics Header */}
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4" id="tour-trial-metrics">
|
||||
<Card className="bg-gradient-to-br from-blue-50 to-transparent dark:from-blue-950/20">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Duration</CardTitle>
|
||||
<Clock className="h-4 w-4 text-blue-500" />
|
||||
@@ -111,7 +204,7 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-purple-50 to-transparent dark:from-purple-950/20">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Robot Actions</CardTitle>
|
||||
<Bot className="h-4 w-4 text-purple-500" />
|
||||
@@ -122,7 +215,7 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-orange-50 to-transparent dark:from-orange-950/20">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Interventions</CardTitle>
|
||||
<AlertTriangle className="h-4 w-4 text-orange-500" />
|
||||
@@ -133,7 +226,7 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-green-50 to-transparent dark:from-green-950/20">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Completeness</CardTitle>
|
||||
<Activity className="h-4 w-4 text-green-500" />
|
||||
@@ -154,54 +247,56 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
||||
</div>
|
||||
|
||||
{/* Main Workspace: Vertical Layout */}
|
||||
<div className="flex-1 min-h-0 rounded-xl border shadow-sm overflow-hidden bg-background">
|
||||
<div className="flex-1 min-h-0 rounded-xl border shadow-sm overflow-hidden bg-background flex flex-col">
|
||||
|
||||
{/* FIXED TIMELINE: Always visible at top */}
|
||||
<div className="shrink-0 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 p-1">
|
||||
<EventTimeline />
|
||||
</div>
|
||||
|
||||
<ResizablePanelGroup direction="vertical">
|
||||
|
||||
{/* TOP: Video & Timeline */}
|
||||
<ResizablePanel defaultSize={50} minSize={30} className="flex flex-col min-h-0 bg-black/5 dark:bg-black/40" id="tour-trial-timeline">
|
||||
<div className="relative flex-1 min-h-0 flex items-center justify-center">
|
||||
{videoUrl ? (
|
||||
<div className="absolute inset-0">
|
||||
<PlaybackPlayer src={videoUrl} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground p-8 text-center">
|
||||
<div className="bg-muted rounded-full p-4 mb-4">
|
||||
<VideoOff className="h-8 w-8 opacity-50" />
|
||||
{/* TOP: Video (Optional) */}
|
||||
{videoUrl && (
|
||||
<>
|
||||
<ResizablePanel defaultSize={40} minSize={20} className="flex flex-col min-h-0 bg-black/5 dark:bg-black/40" id="tour-trial-video">
|
||||
<div className="relative flex-1 min-h-0 flex items-center justify-center">
|
||||
<div className="absolute inset-0">
|
||||
<PlaybackPlayer src={videoUrl} />
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg">No playback media available</h3>
|
||||
<p className="text-sm max-w-sm mt-2">
|
||||
There is no video recording associated with this trial session.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timeline Control */}
|
||||
<div className="shrink-0 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 p-4">
|
||||
<EventTimeline />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle className="bg-border/50" />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle className="bg-border/50" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* BOTTOM: Events Table */}
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="flex flex-col min-h-0 bg-background" id="tour-trial-events">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
||||
<ResizablePanel defaultSize={videoUrl ? 60 : 100} minSize={20} className="flex flex-col min-h-0 bg-background" id="tour-trial-events">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-primary" />
|
||||
<h3 className="font-semibold text-sm">Event Log</h3>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">{events.length} Events</Badge>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4">
|
||||
<EventsDataTable
|
||||
data={events.map(e => ({ ...e, timestamp: new Date(e.timestamp) }))}
|
||||
startTime={trial.startedAt ?? undefined}
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="Filter events..."
|
||||
className="h-8 w-[200px]"
|
||||
disabled
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<Badge variant="secondary" className="text-xs">{events.length} Events</Badge>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4">
|
||||
<EventsDataTable
|
||||
data={events.map(e => ({ ...e, timestamp: new Date(e.timestamp) }))}
|
||||
startTime={trial.startedAt ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
@@ -210,6 +305,9 @@ export function TrialAnalysisView({ trial, backHref }: TrialAnalysisViewProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Helper specific to this file if needed, otherwise ignore.
|
||||
import { Input } from "~/components/ui/input";
|
||||
|
||||
function formatTime(ms: number) {
|
||||
if (ms < 0) return "0:00";
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
|
||||
273
src/components/trials/wizard/RobotSettingsModal.tsx
Normal file
273
src/components/trials/wizard/RobotSettingsModal.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Loader2, Settings2 } from "lucide-react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface RobotSettingsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
studyId: string;
|
||||
pluginId: string;
|
||||
settingsSchema: SettingsSchema | null;
|
||||
}
|
||||
|
||||
interface SettingsSchema {
|
||||
type: "object";
|
||||
title?: string;
|
||||
description?: string;
|
||||
properties: Record<string, PropertySchema>;
|
||||
}
|
||||
|
||||
interface PropertySchema {
|
||||
type: "object" | "string" | "number" | "integer" | "boolean";
|
||||
title?: string;
|
||||
description?: string;
|
||||
properties?: Record<string, PropertySchema>;
|
||||
enum?: string[];
|
||||
enumNames?: string[];
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
default?: unknown;
|
||||
pattern?: string;
|
||||
}
|
||||
|
||||
export function RobotSettingsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
studyId,
|
||||
pluginId,
|
||||
settingsSchema,
|
||||
}: RobotSettingsModalProps) {
|
||||
const [settings, setSettings] = useState<Record<string, unknown>>({});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Fetch current settings
|
||||
const { data: currentSettings, isLoading } = api.studies.getPluginConfiguration.useQuery(
|
||||
{ studyId, pluginId },
|
||||
{ enabled: open }
|
||||
);
|
||||
|
||||
// Update settings mutation
|
||||
const updateSettings = api.studies.updatePluginConfiguration.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Robot settings updated successfully");
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error: { message: string }) => {
|
||||
toast.error(`Failed to update settings: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize settings from current configuration
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useState(() => {
|
||||
if (currentSettings) {
|
||||
setSettings(currentSettings as Record<string, unknown>);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updateSettings.mutateAsync({
|
||||
studyId,
|
||||
pluginId,
|
||||
configuration: settings,
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderField = (key: string, schema: PropertySchema, parentPath: string = "") => {
|
||||
const fullPath = parentPath ? `${parentPath}.${key}` : key;
|
||||
const value = getNestedValue(settings, fullPath);
|
||||
const defaultValue = schema.default;
|
||||
|
||||
const updateValue = (newValue: unknown) => {
|
||||
setSettings((prev) => setNestedValue({ ...prev }, fullPath, newValue));
|
||||
};
|
||||
|
||||
// Object type - render nested fields
|
||||
if (schema.type === "object" && schema.properties) {
|
||||
return (
|
||||
<div key={fullPath} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-sm font-semibold">{schema.title || key}</h4>
|
||||
{schema.description && (
|
||||
<p className="text-xs text-muted-foreground">{schema.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-4 space-y-3">
|
||||
{Object.entries(schema.properties).map(([subKey, subSchema]) =>
|
||||
renderField(subKey, subSchema, fullPath)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Boolean type - render switch
|
||||
if (schema.type === "boolean") {
|
||||
return (
|
||||
<div key={fullPath} className="flex items-center justify-between space-x-2">
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label htmlFor={fullPath}>{schema.title || key}</Label>
|
||||
{schema.description && (
|
||||
<p className="text-xs text-muted-foreground">{schema.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
id={fullPath}
|
||||
checked={(value ?? defaultValue) as boolean}
|
||||
onCheckedChange={updateValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Enum type - render select
|
||||
if (schema.enum) {
|
||||
return (
|
||||
<div key={fullPath} className="space-y-2">
|
||||
<Label htmlFor={fullPath}>{schema.title || key}</Label>
|
||||
{schema.description && (
|
||||
<p className="text-xs text-muted-foreground">{schema.description}</p>
|
||||
)}
|
||||
<Select
|
||||
value={(value ?? defaultValue) as string}
|
||||
onValueChange={updateValue}
|
||||
>
|
||||
<SelectTrigger id={fullPath}>
|
||||
<SelectValue placeholder="Select an option" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{schema.enum.map((option, idx) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{schema.enumNames?.[idx] || option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Number/Integer type - render number input
|
||||
if (schema.type === "number" || schema.type === "integer") {
|
||||
return (
|
||||
<div key={fullPath} className="space-y-2">
|
||||
<Label htmlFor={fullPath}>{schema.title || key}</Label>
|
||||
{schema.description && (
|
||||
<p className="text-xs text-muted-foreground">{schema.description}</p>
|
||||
)}
|
||||
<Input
|
||||
id={fullPath}
|
||||
type="number"
|
||||
min={schema.minimum}
|
||||
max={schema.maximum}
|
||||
step={schema.type === "integer" ? 1 : 0.1}
|
||||
value={(value ?? defaultValue) as number}
|
||||
onChange={(e) => {
|
||||
const newValue = schema.type === "integer"
|
||||
? parseInt(e.target.value, 10)
|
||||
: parseFloat(e.target.value);
|
||||
updateValue(isNaN(newValue) ? defaultValue : newValue);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// String type - render text input
|
||||
return (
|
||||
<div key={fullPath} className="space-y-2">
|
||||
<Label htmlFor={fullPath}>{schema.title || key}</Label>
|
||||
{schema.description && (
|
||||
<p className="text-xs text-muted-foreground">{schema.description}</p>
|
||||
)}
|
||||
<Input
|
||||
id={fullPath}
|
||||
type="text"
|
||||
pattern={schema.pattern}
|
||||
value={(value ?? defaultValue) as string}
|
||||
onChange={(e) => updateValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (!settingsSchema) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Settings2 className="h-5 w-5" />
|
||||
{settingsSchema.title || "Robot Settings"}
|
||||
</DialogTitle>
|
||||
{settingsSchema.description && (
|
||||
<DialogDescription>{settingsSchema.description}</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6 py-4">
|
||||
{Object.entries(settingsSchema.properties).map(([key, schema], idx) => (
|
||||
<div key={key}>
|
||||
{renderField(key, schema)}
|
||||
{idx < Object.keys(settingsSchema.properties).length - 1 && (
|
||||
<Separator className="mt-6" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving || isLoading}>
|
||||
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Save Settings
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper functions for nested object access
|
||||
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
return path.split(".").reduce((current, key) => {
|
||||
return current && typeof current === "object" ? (current as Record<string, unknown>)[key] : undefined;
|
||||
}, obj as unknown);
|
||||
}
|
||||
|
||||
function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): Record<string, unknown> {
|
||||
const keys = path.split(".");
|
||||
const lastKey = keys.pop()!;
|
||||
const target = keys.reduce((current, key) => {
|
||||
if (!current[key] || typeof current[key] !== "object") {
|
||||
current[key] = {};
|
||||
}
|
||||
return current[key] as Record<string, unknown>;
|
||||
}, obj);
|
||||
target[lastKey] = value;
|
||||
return obj;
|
||||
}
|
||||
@@ -113,7 +113,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
|
||||
// UI State
|
||||
const [executionPanelTab, setExecutionPanelTab] = useState<"current" | "timeline" | "events">("timeline");
|
||||
const [obsTab, setObsTab] = useState<"notes" | "timeline">("notes");
|
||||
|
||||
const [isExecutingAction, setIsExecutingAction] = useState(false);
|
||||
const [monitoringPanelTab, setMonitoringPanelTab] = useState<
|
||||
"status" | "robot" | "events"
|
||||
@@ -202,13 +202,23 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
connect: connectRos,
|
||||
disconnect: disconnectRos,
|
||||
executeRobotAction: executeRosAction,
|
||||
setAutonomousLife,
|
||||
setAutonomousLife: setAutonomousLifeRaw,
|
||||
} = useWizardRos({
|
||||
autoConnect: true,
|
||||
onActionCompleted,
|
||||
onActionFailed,
|
||||
});
|
||||
|
||||
// Wrap setAutonomousLife in a stable callback to prevent infinite re-renders
|
||||
// The raw function from useWizardRos is recreated when isConnected changes,
|
||||
// which would cause WizardControlPanel (wrapped in React.memo) to re-render infinitely
|
||||
const setAutonomousLife = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
return setAutonomousLifeRaw(enabled);
|
||||
},
|
||||
[setAutonomousLifeRaw]
|
||||
);
|
||||
|
||||
// Use polling for trial status updates (no trial WebSocket server exists)
|
||||
const { data: pollingData } = api.trials.get.useQuery(
|
||||
{ id: trial.id },
|
||||
@@ -237,19 +247,28 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
pollingData.startedAt?.getTime() !== trial.startedAt?.getTime() ||
|
||||
pollingData.completedAt?.getTime() !== trial.completedAt?.getTime()) {
|
||||
|
||||
setTrial((prev) => ({
|
||||
...prev,
|
||||
status: pollingData.status,
|
||||
startedAt: pollingData.startedAt
|
||||
? new Date(pollingData.startedAt)
|
||||
: prev.startedAt,
|
||||
completedAt: pollingData.completedAt
|
||||
? new Date(pollingData.completedAt)
|
||||
: prev.completedAt,
|
||||
}));
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [pollingData, trial]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pollingData]);
|
||||
|
||||
// Auto-start trial on mount if scheduled
|
||||
useEffect(() => {
|
||||
@@ -259,7 +278,6 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
}, []); // Run once on mount
|
||||
|
||||
// Trial events from robot actions
|
||||
|
||||
const trialEvents = useMemo<
|
||||
Array<{
|
||||
type: string;
|
||||
@@ -301,7 +319,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
}, [fetchedEvents]);
|
||||
|
||||
// Transform experiment steps to component format
|
||||
const steps: StepData[] =
|
||||
const steps: StepData[] = useMemo(() =>
|
||||
experimentSteps?.map((step, index) => ({
|
||||
id: step.id,
|
||||
name: step.name ?? `Step ${index + 1}`,
|
||||
@@ -320,7 +338,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
order: action.order,
|
||||
pluginId: action.pluginId,
|
||||
})) ?? [],
|
||||
})) ?? [];
|
||||
})) ?? [], [experimentSteps]);
|
||||
|
||||
|
||||
const currentStep = steps[currentStepIndex] ?? null;
|
||||
const totalSteps = steps.length;
|
||||
@@ -451,6 +470,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
const result = await startTrialMutation.mutateAsync({ id: trial.id });
|
||||
console.log("[WizardInterface] Trial started successfully", result);
|
||||
|
||||
|
||||
|
||||
// Update local state immediately
|
||||
setTrial((prev) => ({
|
||||
...prev,
|
||||
@@ -471,6 +492,11 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
const handlePauseTrial = async () => {
|
||||
try {
|
||||
await pauseTrialMutation.mutateAsync({ id: trial.id });
|
||||
logEventMutation.mutate({
|
||||
trialId: trial.id,
|
||||
type: "trial_paused",
|
||||
data: { timestamp: new Date() }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to pause trial:", error);
|
||||
}
|
||||
@@ -482,6 +508,20 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
// Find step by index to ensure safety
|
||||
if (targetIndex >= 0 && targetIndex < steps.length) {
|
||||
console.log(`[WizardInterface] Manual jump to step ${targetIndex}`);
|
||||
|
||||
// Log manual jump
|
||||
logEventMutation.mutate({
|
||||
trialId: trial.id,
|
||||
type: "step_jumped",
|
||||
data: {
|
||||
fromIndex: currentStepIndex,
|
||||
toIndex: targetIndex,
|
||||
fromStepId: steps[currentStepIndex]?.id,
|
||||
toStepId: steps[targetIndex]?.id,
|
||||
reason: "manual_choice"
|
||||
}
|
||||
});
|
||||
|
||||
setCompletedActionsCount(0);
|
||||
setCurrentStepIndex(targetIndex);
|
||||
setLastResponse(null);
|
||||
@@ -500,6 +540,18 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
const targetIndex = steps.findIndex(s => s.id === matchedOption.nextStepId);
|
||||
if (targetIndex !== -1) {
|
||||
console.log(`[WizardInterface] Branching to step ${targetIndex} (${matchedOption.label})`);
|
||||
|
||||
logEventMutation.mutate({
|
||||
trialId: trial.id,
|
||||
type: "step_branched",
|
||||
data: {
|
||||
fromIndex: currentStepIndex,
|
||||
toIndex: targetIndex,
|
||||
condition: matchedOption.label,
|
||||
value: lastResponse
|
||||
}
|
||||
});
|
||||
|
||||
setCurrentStepIndex(targetIndex);
|
||||
setLastResponse(null); // Reset after consuming
|
||||
return;
|
||||
@@ -514,6 +566,17 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
const targetIndex = steps.findIndex(s => s.id === nextId);
|
||||
if (targetIndex !== -1) {
|
||||
console.log(`[WizardInterface] Condition-based jump to step ${targetIndex} (${nextId})`);
|
||||
|
||||
logEventMutation.mutate({
|
||||
trialId: trial.id,
|
||||
type: "step_jumped",
|
||||
data: {
|
||||
fromIndex: currentStepIndex,
|
||||
toIndex: targetIndex,
|
||||
reason: "condition_next_step"
|
||||
}
|
||||
});
|
||||
|
||||
setCurrentStepIndex(targetIndex);
|
||||
setCompletedActionsCount(0);
|
||||
return;
|
||||
@@ -549,6 +612,9 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
const handleCompleteTrial = async () => {
|
||||
try {
|
||||
await completeTrialMutation.mutateAsync({ id: trial.id });
|
||||
|
||||
|
||||
|
||||
// Trigger archive in background
|
||||
archiveTrialMutation.mutate({ id: trial.id });
|
||||
} catch (error) {
|
||||
@@ -559,6 +625,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
const handleAbortTrial = async () => {
|
||||
try {
|
||||
await abortTrialMutation.mutateAsync({ id: trial.id });
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to abort trial:", error);
|
||||
}
|
||||
@@ -638,6 +706,16 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
description: String(parameters?.content || "Quick note"),
|
||||
category: String(parameters?.category || "quick_note")
|
||||
});
|
||||
} else {
|
||||
// Generic action logging
|
||||
await logEventMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
type: "action_executed",
|
||||
data: {
|
||||
actionId,
|
||||
parameters
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Note: Action execution can be enhanced later with tRPC mutations
|
||||
@@ -733,14 +811,27 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
options?: { autoAdvance?: boolean },
|
||||
) => {
|
||||
try {
|
||||
await logRobotActionMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
pluginName,
|
||||
actionId,
|
||||
parameters,
|
||||
duration: 0,
|
||||
result: { skipped: true },
|
||||
});
|
||||
// If it's a robot action (indicated by pluginName), use the robot logger
|
||||
if (pluginName) {
|
||||
await logRobotActionMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
pluginName,
|
||||
actionId,
|
||||
parameters,
|
||||
duration: 0,
|
||||
result: { skipped: true },
|
||||
});
|
||||
} else {
|
||||
// Generic skip logging
|
||||
await logEventMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
type: "action_skipped",
|
||||
data: {
|
||||
actionId,
|
||||
parameters
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toast.info(`Action skipped: ${actionId}`);
|
||||
if (options?.autoAdvance) {
|
||||
@@ -849,8 +940,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto min-h-0 bg-muted/10">
|
||||
<div id="tour-wizard-controls" className="h-full">
|
||||
<div className="flex-1 overflow-hidden min-h-0 bg-muted/10">
|
||||
<div id="tour-wizard-controls-wrapper" className="h-full">
|
||||
<WizardControlPanel
|
||||
trial={trial}
|
||||
currentStep={currentStep}
|
||||
@@ -862,11 +953,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
onCompleteTrial={handleCompleteTrial}
|
||||
onAbortTrial={handleAbortTrial}
|
||||
onExecuteAction={handleExecuteAction}
|
||||
onExecuteRobotAction={handleExecuteRobotAction}
|
||||
studyId={trial.experiment.studyId}
|
||||
_isConnected={rosConnected}
|
||||
isStarting={startTrialMutation.isPending}
|
||||
onSetAutonomousLife={setAutonomousLife}
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
/>
|
||||
</div>
|
||||
@@ -937,6 +1024,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
onActionCompleted={() => setCompletedActionsCount(c => c + 1)}
|
||||
onCompleteTrial={handleCompleteTrial}
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
rosConnected={rosConnected}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -946,7 +1034,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
{!rightCollapsed && (
|
||||
<div className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm w-80">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
|
||||
<span className="text-sm font-medium">Robot Status</span>
|
||||
<span className="text-sm font-medium">Robot Control & Status</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -966,6 +1054,10 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
connectRos={connectRos}
|
||||
disconnectRos={disconnectRos}
|
||||
executeRosAction={executeRosAction}
|
||||
onSetAutonomousLife={setAutonomousLife}
|
||||
onExecuteRobotAction={handleExecuteRobotAction}
|
||||
studyId={trial.experiment.studyId}
|
||||
trialId={trial.id}
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
/>
|
||||
</div>
|
||||
@@ -976,13 +1068,9 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
|
||||
{/* Bottom Row - Observations (Full Width, Collapsible) */}
|
||||
{!obsCollapsed && (
|
||||
<Tabs value={obsTab} onValueChange={(v) => setObsTab(v as "notes" | "timeline")} className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm h-48 flex-none">
|
||||
<div className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm h-48 flex-none">
|
||||
<div className="flex items-center border-b px-3 py-2 bg-muted/30 gap-3">
|
||||
<span className="text-sm font-medium">Observations</span>
|
||||
<TabsList className="h-7 bg-transparent border-0 p-0">
|
||||
<TabsTrigger value="notes" className="text-xs h-7 px-3">Notes</TabsTrigger>
|
||||
<TabsTrigger value="timeline" className="text-xs h-7 px-3">Timeline</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -999,10 +1087,9 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
isSubmitting={addAnnotationMutation.isPending}
|
||||
trialEvents={trialEvents}
|
||||
readOnly={trial.status === 'completed'}
|
||||
activeTab={obsTab}
|
||||
/>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
obsCollapsed && (
|
||||
|
||||
498
src/components/trials/wizard/panels/WizardActionItem.tsx
Normal file
498
src/components/trials/wizard/panels/WizardActionItem.tsx
Normal file
@@ -0,0 +1,498 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import {
|
||||
Play,
|
||||
CheckCircle,
|
||||
RotateCcw,
|
||||
Clock,
|
||||
Repeat,
|
||||
Split,
|
||||
Layers,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
|
||||
export interface ActionData {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: string;
|
||||
parameters: Record<string, unknown>;
|
||||
order: number;
|
||||
pluginId: string | null;
|
||||
}
|
||||
|
||||
interface WizardActionItemProps {
|
||||
action: ActionData;
|
||||
index: number;
|
||||
isActive: boolean;
|
||||
isCompleted: boolean;
|
||||
onExecute: (actionId: string, parameters?: Record<string, unknown>) => void;
|
||||
onExecuteRobot: (
|
||||
pluginName: string,
|
||||
actionId: string,
|
||||
parameters: Record<string, unknown>,
|
||||
options?: { autoAdvance?: boolean }
|
||||
) => Promise<void>;
|
||||
onSkip: (
|
||||
pluginName: string,
|
||||
actionId: string,
|
||||
parameters: Record<string, unknown>,
|
||||
options?: { autoAdvance?: boolean }
|
||||
) => Promise<void>;
|
||||
onCompleted: () => void;
|
||||
readOnly?: boolean;
|
||||
isExecuting?: boolean;
|
||||
depth?: number;
|
||||
isRobotConnected?: boolean;
|
||||
}
|
||||
|
||||
export function WizardActionItem({
|
||||
action,
|
||||
index,
|
||||
isActive,
|
||||
isCompleted,
|
||||
onExecute,
|
||||
onExecuteRobot,
|
||||
onSkip,
|
||||
onCompleted,
|
||||
readOnly,
|
||||
isExecuting,
|
||||
depth = 0,
|
||||
isRobotConnected = false,
|
||||
}: WizardActionItemProps): React.JSX.Element {
|
||||
// Local state for container children completion
|
||||
const [completedChildren, setCompletedChildren] = useState<Set<number>>(new Set());
|
||||
// Local state for loop iterations
|
||||
const [currentIteration, setCurrentIteration] = useState(1);
|
||||
// Local state to track execution of this specific item
|
||||
const [isRunningLocal, setIsRunningLocal] = useState(false);
|
||||
// Local state for wait countdown
|
||||
const [countdown, setCountdown] = useState<number | null>(null);
|
||||
|
||||
const isContainer =
|
||||
action.type === "hristudio-core.sequence" ||
|
||||
action.type === "hristudio-core.parallel" ||
|
||||
action.type === "hristudio-core.loop" ||
|
||||
action.type === "sequence" ||
|
||||
action.type === "parallel" ||
|
||||
action.type === "loop";
|
||||
|
||||
// Branch support
|
||||
const isBranch = action.type === "hristudio-core.branch" || action.type === "branch";
|
||||
const isWait = action.type === "hristudio-core.wait" || action.type === "wait";
|
||||
|
||||
// Helper to get children
|
||||
const children = (action.parameters.children as ActionData[]) || [];
|
||||
const iterations = (action.parameters.iterations as number) || 1;
|
||||
|
||||
// Recursive helper to check for robot actions
|
||||
const hasRobotActions = useCallback((item: ActionData): boolean => {
|
||||
if (item.type === "robot_action" || !!item.pluginId) return true;
|
||||
if (item.parameters?.children && Array.isArray(item.parameters.children)) {
|
||||
return (item.parameters.children as ActionData[]).some(hasRobotActions);
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const containsRobotActions = hasRobotActions(action);
|
||||
|
||||
// Countdown effect
|
||||
React.useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
if (isRunningLocal && countdown !== null && countdown > 0) {
|
||||
interval = setInterval(() => {
|
||||
setCountdown((prev) => (prev !== null && prev > 0 ? prev - 1 : 0));
|
||||
}, 1000);
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [isRunningLocal, countdown]);
|
||||
|
||||
// Derived state for disabled button
|
||||
const isButtonDisabled =
|
||||
isExecuting ||
|
||||
isRunningLocal ||
|
||||
(!isWait && !isRobotConnected && (action.type === 'robot_action' || !!action.pluginId || (isContainer && containsRobotActions)));
|
||||
|
||||
|
||||
// Handler for child completion
|
||||
const handleChildCompleted = useCallback((childIndex: number) => {
|
||||
setCompletedChildren(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(childIndex);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handler for next loop iteration
|
||||
const handleNextIteration = useCallback(() => {
|
||||
if (currentIteration < iterations) {
|
||||
setCompletedChildren(new Set());
|
||||
setCurrentIteration(prev => prev + 1);
|
||||
} else {
|
||||
// Loop finished - allow manual completion of the loop action
|
||||
}
|
||||
}, [currentIteration, iterations]);
|
||||
|
||||
// Check if current iteration is complete (all children done)
|
||||
const isIterationComplete = children.length > 0 && children.every((_, idx) => completedChildren.has(idx));
|
||||
const isLoopComplete = isIterationComplete && currentIteration >= iterations;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative pb-2 last:pb-0 transition-all duration-300",
|
||||
depth > 0 && "ml-4 mt-2 border-l pl-4 border-l-border/30"
|
||||
)}
|
||||
>
|
||||
{/* Visual Connection Line for Root items is handled by parent list,
|
||||
but for nested items we handle it via border-l above */}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border transition-all duration-300",
|
||||
isActive
|
||||
? "bg-card border-primary/50 shadow-md p-4"
|
||||
: "bg-muted/5 border-transparent p-3 opacity-80 hover:opacity-100",
|
||||
isContainer && "bg-muted/10 border-border/50"
|
||||
)}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{/* Header Row */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Icon based on type */}
|
||||
{isContainer && action.type.includes("loop") && <Repeat className="h-4 w-4 text-blue-500 dark:text-blue-400" />}
|
||||
{isContainer && action.type.includes("parallel") && <Layers className="h-4 w-4 text-purple-500 dark:text-purple-400" />}
|
||||
{isBranch && <Split className="h-4 w-4 text-orange-500 dark:text-orange-400" />}
|
||||
{isWait && <Clock className="h-4 w-4 text-amber-500 dark:text-amber-400" />}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"text-base font-medium leading-none",
|
||||
isCompleted && "line-through text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{action.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Completion Badge */}
|
||||
{isCompleted && <CheckCircle className="h-4 w-4 text-green-500 dark:text-green-400" />}
|
||||
</div>
|
||||
|
||||
{action.description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{action.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details for Control Flow */}
|
||||
{isWait && (
|
||||
<div className="flex items-center gap-2 text-xs text-amber-700 bg-amber-50/80 dark:text-amber-300 dark:bg-amber-900/30 w-fit px-2 py-1 rounded border border-amber-100 dark:border-amber-800/50">
|
||||
<Clock className="h-3 w-3" />
|
||||
Wait {String(action.parameters.duration || 1)}s
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action.type.includes("loop") && (
|
||||
<div className="flex items-center gap-2 text-xs text-blue-700 bg-blue-50/80 dark:text-blue-300 dark:bg-blue-900/30 w-fit px-2 py-1 rounded border border-blue-100 dark:border-blue-800/50">
|
||||
<Repeat className="h-3 w-3" />
|
||||
{String(action.parameters.iterations || 1)} Iterations
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{((!!isContainer && children.length > 0) ? (
|
||||
<div className="mt-4 space-y-2">
|
||||
{/* Loop Iteration Status & Controls */}
|
||||
{action.type.includes("loop") && (
|
||||
<div className="flex items-center justify-between bg-blue-50/50 dark:bg-blue-900/20 p-2 rounded mb-2 border border-blue-100 dark:border-blue-800/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="bg-white dark:bg-zinc-900 dark:text-zinc-100 border-zinc-200 dark:border-zinc-700">
|
||||
Iteration {currentIteration} of {iterations}
|
||||
</Badge>
|
||||
{isIterationComplete && currentIteration < iterations && (
|
||||
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium animate-pulse">
|
||||
All actions complete. Ready for next iteration.
|
||||
</span>
|
||||
)}
|
||||
{isLoopComplete && (
|
||||
<span className="text-xs text-green-600 dark:text-green-400 font-medium">
|
||||
Loop complete!
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoopComplete ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onCompleted();
|
||||
}}
|
||||
className="h-7 text-xs bg-green-600 hover:bg-green-700 text-white dark:bg-green-600 dark:hover:bg-green-500"
|
||||
>
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
Finish Loop
|
||||
</Button>
|
||||
) : (
|
||||
isIterationComplete && currentIteration < iterations && !readOnly && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onCompleted();
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<ChevronRight className="mr-1 h-3 w-3" />
|
||||
Exit Loop
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleNextIteration();
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<Repeat className="mr-1 h-3 w-3" />
|
||||
Next Iteration
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
{action.type.includes("loop") ? "Loop Body" : "Actions"}
|
||||
</div>
|
||||
|
||||
{children.map((child, idx) => (
|
||||
<WizardActionItem
|
||||
key={`${child.id || idx}-${currentIteration}`}
|
||||
action={child as ActionData}
|
||||
index={idx}
|
||||
isActive={isActive && !isCompleted && !completedChildren.has(idx)}
|
||||
isCompleted={isCompleted || completedChildren.has(idx)}
|
||||
onExecute={onExecute}
|
||||
onExecuteRobot={onExecuteRobot}
|
||||
onSkip={onSkip}
|
||||
onCompleted={() => handleChildCompleted(idx)}
|
||||
readOnly={readOnly || isCompleted || completedChildren.has(idx) || (action.type.includes("parallel") && true)}
|
||||
isExecuting={isExecuting}
|
||||
depth={depth + 1}
|
||||
isRobotConnected={isRobotConnected}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null) as any}
|
||||
|
||||
{/* Active Action Controls */}
|
||||
{isActive && !readOnly && (
|
||||
<div className="pt-3 flex flex-wrap items-center gap-3">
|
||||
{/* Parallel Container Controls */}
|
||||
{isContainer && action.type.includes("parallel") ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
className={cn(
|
||||
"shadow-sm min-w-[100px]",
|
||||
isButtonDisabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
// Run all child robot actions
|
||||
const children = (action.parameters.children as ActionData[]) || [];
|
||||
for (const child of children) {
|
||||
if (child.pluginId) {
|
||||
// Fire and forget - don't await sequentially
|
||||
onExecuteRobot(
|
||||
child.pluginId,
|
||||
child.type.includes(".") ? child.type.split(".").pop()! : child.type,
|
||||
child.parameters || {},
|
||||
{ autoAdvance: false }
|
||||
).catch(console.error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={isButtonDisabled}
|
||||
title={isButtonDisabled && !isExecuting ? "Robot disconnected" : undefined}
|
||||
>
|
||||
<Play className="mr-2 h-3.5 w-3.5" />
|
||||
Run All
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onCompleted();
|
||||
}}
|
||||
disabled={isExecuting}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-3.5 w-3.5" />
|
||||
Mark Group Complete
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
/* Standard Single Action Controls */
|
||||
(action.pluginId && !["hristudio-woz"].includes(action.pluginId!) && (action.pluginId !== "hristudio-core" || isWait)) ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
className={cn(
|
||||
"shadow-sm min-w-[100px]",
|
||||
isButtonDisabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
setIsRunningLocal(true);
|
||||
|
||||
if (isWait) {
|
||||
const duration = Number(action.parameters.duration || 1);
|
||||
setCountdown(Math.ceil(duration));
|
||||
}
|
||||
|
||||
try {
|
||||
await onExecuteRobot(
|
||||
action.pluginId!,
|
||||
action.type.includes(".") ? action.type.split(".").pop()! : action.type,
|
||||
action.parameters || {},
|
||||
{ autoAdvance: false }
|
||||
);
|
||||
onCompleted();
|
||||
} catch (error) {
|
||||
console.error("Action execution error:", error);
|
||||
} finally {
|
||||
setIsRunningLocal(false);
|
||||
setCountdown(null);
|
||||
}
|
||||
}}
|
||||
disabled={isExecuting || isRunningLocal || (!isWait && !isRobotConnected)}
|
||||
title={!isWait && !isRobotConnected ? "Robot disconnected" : undefined}
|
||||
>
|
||||
{isRunningLocal ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
{isWait ? (countdown !== null && countdown > 0 ? `Wait (${countdown}s)...` : "Finishing...") : "Running..."}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 h-3.5 w-3.5" />
|
||||
Run
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onCompleted();
|
||||
}}
|
||||
disabled={isExecuting}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-3.5 w-3.5" />
|
||||
Mark Complete
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (onSkip) {
|
||||
onSkip(action.pluginId!, action.type.includes(".") ? action.type.split(".").pop()! : action.type, action.parameters || {}, { autoAdvance: false });
|
||||
}
|
||||
onCompleted();
|
||||
}}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
// Manual/Wizard Actions (Leaf nodes)
|
||||
!isContainer && action.type !== "wizard_wait_for_response" && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onCompleted();
|
||||
}}
|
||||
disabled={isExecuting}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-3.5 w-3.5" />
|
||||
Mark Complete
|
||||
</Button>
|
||||
)
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Branching / Choice UI */}
|
||||
{isActive &&
|
||||
(action.type === "wizard_wait_for_response" || isBranch) &&
|
||||
action.parameters?.options &&
|
||||
Array.isArray(action.parameters.options) && (
|
||||
<div className="pt-3 grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{(action.parameters.options as any[]).map((opt, optIdx) => {
|
||||
const label = typeof opt === "string" ? opt : opt.label;
|
||||
const value = typeof opt === "string" ? opt : opt.value;
|
||||
const nextStepId = typeof opt === "object" ? opt.nextStepId : undefined;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={optIdx}
|
||||
variant="outline"
|
||||
className="justify-start h-auto py-3 px-4 text-left hover:border-primary hover:bg-primary/5"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onExecute(action.id, { value, label, nextStepId });
|
||||
onCompleted();
|
||||
}}
|
||||
disabled={readOnly || isExecuting}
|
||||
>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<span className="font-medium">{String(label)}</span>
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Retry for failed/completed robot actions */}
|
||||
{isCompleted && action.pluginId && !isContainer && (
|
||||
<div className="pt-1 flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-primary"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onExecuteRobot(
|
||||
action.pluginId!,
|
||||
action.type.includes(".") ? action.type.split(".").pop()! : action.type,
|
||||
action.parameters || {},
|
||||
{ autoAdvance: false }
|
||||
);
|
||||
}}
|
||||
disabled={isExecuting}
|
||||
>
|
||||
<RotateCcw className="mr-1.5 h-3 w-3" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,12 +18,8 @@ import {
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { RobotActionsPanel } from "../RobotActionsPanel";
|
||||
|
||||
interface StepData {
|
||||
id: string;
|
||||
@@ -95,16 +91,7 @@ interface WizardControlPanelProps {
|
||||
actionId: string,
|
||||
parameters?: Record<string, unknown>,
|
||||
) => void;
|
||||
onExecuteRobotAction?: (
|
||||
pluginName: string,
|
||||
actionId: string,
|
||||
parameters: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
studyId?: string;
|
||||
_isConnected: boolean;
|
||||
|
||||
isStarting?: boolean;
|
||||
onSetAutonomousLife?: (enabled: boolean) => Promise<boolean | void>;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
@@ -119,30 +106,10 @@ export const WizardControlPanel = React.memo(function WizardControlPanel({
|
||||
onCompleteTrial,
|
||||
onAbortTrial,
|
||||
onExecuteAction,
|
||||
onExecuteRobotAction,
|
||||
studyId,
|
||||
_isConnected,
|
||||
isStarting = false,
|
||||
onSetAutonomousLife,
|
||||
readOnly = false,
|
||||
}: WizardControlPanelProps) {
|
||||
const [autonomousLife, setAutonomousLife] = React.useState(true);
|
||||
|
||||
const handleAutonomousLifeChange = async (checked: boolean) => {
|
||||
setAutonomousLife(checked); // Optimistic update
|
||||
if (onSetAutonomousLife) {
|
||||
try {
|
||||
const result = await onSetAutonomousLife(checked);
|
||||
if (result === false) {
|
||||
throw new Error("Service unavailable");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to set autonomous life:", error);
|
||||
setAutonomousLife(!checked); // Revert on failure
|
||||
// Optional: Toast error?
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col" id="tour-wizard-controls">
|
||||
@@ -170,7 +137,7 @@ export const WizardControlPanel = React.memo(function WizardControlPanel({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800"
|
||||
className="w-full justify-start border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300 dark:border-yellow-700/50 dark:hover:bg-yellow-900/40"
|
||||
onClick={() => onExecuteAction("intervene")}
|
||||
disabled={readOnly}
|
||||
>
|
||||
@@ -207,50 +174,27 @@ export const WizardControlPanel = React.memo(function WizardControlPanel({
|
||||
Controls available during trial
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Robot Controls (Merged from System & Robot Tab) */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">Connection</span>
|
||||
{_isConnected ? (
|
||||
<Badge variant="default" className="bg-green-600 text-xs">Connected</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-muted-foreground border-muted-foreground/30 text-xs">Offline</Badge>
|
||||
)}
|
||||
{/* Step Navigation */}
|
||||
<div className="pt-4 border-t space-y-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Navigation</span>
|
||||
<select
|
||||
className="w-full text-xs p-2 rounded-md border bg-background"
|
||||
value={currentStepIndex}
|
||||
onChange={(e) => onNextStep(parseInt(e.target.value, 10))}
|
||||
disabled={readOnly}
|
||||
>
|
||||
{steps.map((step, idx) => (
|
||||
<option key={step.id} value={idx}>
|
||||
{idx + 1}. {step.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="autonomous-life" className="text-xs font-normal text-muted-foreground">Autonomous Life</Label>
|
||||
<Switch
|
||||
id="tour-wizard-autonomous"
|
||||
checked={!!autonomousLife}
|
||||
onCheckedChange={handleAutonomousLifeChange}
|
||||
disabled={!_isConnected || readOnly}
|
||||
className="scale-75"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Robot Actions Panel Integration */}
|
||||
{studyId && onExecuteRobotAction ? (
|
||||
<div className={readOnly ? "pointer-events-none opacity-50" : ""}>
|
||||
<RobotActionsPanel
|
||||
studyId={studyId}
|
||||
trialId={trial.id}
|
||||
onExecuteAction={onExecuteRobotAction}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground text-center py-2">Robot actions unavailable</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div >
|
||||
</div >
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
|
||||
import React from "react";
|
||||
import { WizardActionItem } from "./WizardActionItem";
|
||||
import {
|
||||
Play,
|
||||
SkipForward,
|
||||
@@ -111,6 +113,7 @@ interface WizardExecutionPanelProps {
|
||||
completedActionsCount: number;
|
||||
onActionCompleted: () => void;
|
||||
readOnly?: boolean;
|
||||
rosConnected?: boolean;
|
||||
}
|
||||
|
||||
export function WizardExecutionPanel({
|
||||
@@ -131,6 +134,7 @@ export function WizardExecutionPanel({
|
||||
completedActionsCount,
|
||||
onActionCompleted,
|
||||
readOnly = false,
|
||||
rosConnected,
|
||||
}: WizardExecutionPanelProps) {
|
||||
// Local state removed in favor of parent state to prevent reset on re-render
|
||||
// const [completedCount, setCompletedCount] = React.useState(0);
|
||||
@@ -207,11 +211,85 @@ export function WizardExecutionPanel({
|
||||
// Active trial state
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<ScrollArea className="h-full w-full">
|
||||
{/* Horizontal Step Progress Bar */}
|
||||
<div className="flex-none border-b bg-muted/30 p-3">
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-2">
|
||||
{steps.map((step, idx) => {
|
||||
const isCurrent = idx === currentStepIndex;
|
||||
const isCompleted = idx < currentStepIndex;
|
||||
const isUpcoming = idx > currentStepIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className="flex items-center gap-2 flex-shrink-0"
|
||||
>
|
||||
<button
|
||||
onClick={() => onStepSelect(idx)}
|
||||
disabled={readOnly}
|
||||
className={`
|
||||
group relative flex items-center gap-2 rounded-lg border-2 px-3 py-2 transition-all
|
||||
${isCurrent
|
||||
? "border-primary bg-primary/10 shadow-sm"
|
||||
: isCompleted
|
||||
? "border-primary/30 bg-primary/5 hover:bg-primary/10"
|
||||
: "border-muted-foreground/20 bg-background hover:bg-muted/50"
|
||||
}
|
||||
${readOnly ? "cursor-default" : "cursor-pointer"}
|
||||
`}
|
||||
>
|
||||
{/* Step Number/Icon */}
|
||||
<div
|
||||
className={`
|
||||
flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold
|
||||
${isCompleted
|
||||
? "bg-primary text-primary-foreground"
|
||||
: isCurrent
|
||||
? "bg-primary text-primary-foreground ring-2 ring-primary/20"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
idx + 1
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step Name */}
|
||||
<span
|
||||
className={`text-xs font-medium max-w-[120px] truncate ${isCurrent
|
||||
? "text-foreground"
|
||||
: isCompleted
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/60"
|
||||
}`}
|
||||
title={step.name}
|
||||
>
|
||||
{step.name}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Arrow Connector */}
|
||||
{idx < steps.length - 1 && (
|
||||
<ArrowRight
|
||||
className={`h-4 w-4 flex-shrink-0 ${isCompleted ? "text-primary/40" : "text-muted-foreground/30"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Step Details - NO SCROLL */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="pr-4">
|
||||
{currentStep ? (
|
||||
<div className="flex flex-col gap-4 p-4 max-w-2xl mx-auto">
|
||||
<div className="flex flex-col gap-4 p-4 max-w-5xl mx-auto w-full">
|
||||
{/* Header Info */}
|
||||
<div className="space-y-1 pb-4 border-b">
|
||||
<h2 className="text-xl font-bold tracking-tight">{currentStep.name}</h2>
|
||||
@@ -226,7 +304,7 @@ export function WizardExecutionPanel({
|
||||
{currentStep.actions.map((action, idx) => {
|
||||
const isCompleted = idx < activeActionIndex;
|
||||
const isActive: boolean = idx === activeActionIndex;
|
||||
const isLast = idx === currentStep.actions!.length - 1;
|
||||
const isLast = idx === (currentStep.actions?.length || 0) - 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -257,176 +335,25 @@ export function WizardExecutionPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Card */}
|
||||
<div
|
||||
className={`rounded-lg border transition-all duration-300 ${isActive
|
||||
? "bg-card border-primary/50 shadow-md p-5 translate-x-1"
|
||||
: "bg-muted/5 border-transparent p-3 opacity-70 hover:opacity-100"
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div
|
||||
className={`text-base font-medium leading-none ${isCompleted ? "line-through text-muted-foreground" : ""
|
||||
}`}
|
||||
>
|
||||
{action.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{action.description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{action.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Action Controls */}
|
||||
{isActive === true ? (
|
||||
<div className="pt-3 flex items-center gap-3">
|
||||
{action.pluginId && !["hristudio-core", "hristudio-woz"].includes(action.pluginId) ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
className="shadow-sm min-w-[100px]"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onExecuteRobotAction(
|
||||
action.pluginId!,
|
||||
action.type.includes(".")
|
||||
? action.type.split(".").pop()!
|
||||
: action.type,
|
||||
action.parameters || {},
|
||||
{ autoAdvance: false }
|
||||
);
|
||||
onActionCompleted();
|
||||
}}
|
||||
disabled={readOnly || isExecuting}
|
||||
>
|
||||
<Play className="mr-2 h-3.5 w-3.5" />
|
||||
Execute
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onSkipAction(
|
||||
action.pluginId!,
|
||||
action.type.includes(".")
|
||||
? action.type.split(".").pop()!
|
||||
: action.type,
|
||||
action.parameters || {},
|
||||
{ autoAdvance: false }
|
||||
);
|
||||
onActionCompleted();
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onActionCompleted();
|
||||
}}
|
||||
disabled={readOnly || isExecuting}
|
||||
>
|
||||
Mark Done
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Wizard Wait For Response / Branching UI */}
|
||||
{isActive === true &&
|
||||
action.type === "wizard_wait_for_response" &&
|
||||
action.parameters?.options &&
|
||||
Array.isArray(action.parameters.options) ? (
|
||||
<div className="pt-3 grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{(action.parameters.options as any[]).map(
|
||||
(opt, optIdx) => {
|
||||
// Handle both string options and object options
|
||||
const label =
|
||||
typeof opt === "string"
|
||||
? opt
|
||||
: opt.label;
|
||||
const value =
|
||||
typeof opt === "string"
|
||||
? opt
|
||||
: opt.value;
|
||||
const nextStepId =
|
||||
typeof opt === "object"
|
||||
? opt.nextStepId
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={optIdx}
|
||||
variant="outline"
|
||||
className="justify-start h-auto py-3 px-4 text-left border-primary/20 hover:border-primary hover:bg-primary/5"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onExecuteAction(action.id, {
|
||||
value,
|
||||
label,
|
||||
nextStepId,
|
||||
});
|
||||
onActionCompleted();
|
||||
}}
|
||||
disabled={readOnly || isExecuting}
|
||||
>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<span className="font-medium">
|
||||
{String(label)}
|
||||
</span>
|
||||
{typeof opt !== "string" && value && (
|
||||
<span className="text-xs text-muted-foreground font-mono bg-muted px-1.5 py-0.5 rounded-sm">
|
||||
{String(value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Completed State Actions */}
|
||||
{isCompleted && action.pluginId && (
|
||||
<div className="pt-1 flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-primary"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onExecuteRobotAction(
|
||||
action.pluginId!,
|
||||
action.type.includes(".") ? action.type.split(".").pop()! : action.type,
|
||||
action.parameters || {},
|
||||
{ autoAdvance: false },
|
||||
);
|
||||
}}
|
||||
disabled={readOnly || isExecuting}
|
||||
>
|
||||
<RotateCcw className="mr-1.5 h-3 w-3" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Action Content */}
|
||||
<WizardActionItem
|
||||
action={action as any} // Cast to ActionData
|
||||
index={idx}
|
||||
isActive={isActive}
|
||||
isCompleted={isCompleted}
|
||||
onExecute={onExecuteAction}
|
||||
onExecuteRobot={onExecuteRobotAction}
|
||||
onSkip={onSkipAction}
|
||||
onCompleted={onActionCompleted}
|
||||
readOnly={readOnly}
|
||||
isExecuting={isExecuting}
|
||||
isRobotConnected={rosConnected}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)}
|
||||
|
||||
{/* Manual Advance Button */}
|
||||
{activeActionIndex >= (currentStep.actions?.length || 0) && (
|
||||
@@ -453,7 +380,7 @@ export function WizardExecutionPanel({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,10 @@ import { Separator } from "~/components/ui/separator";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { WebcamPanel } from "./WebcamPanel";
|
||||
import { RobotActionsPanel } from "../RobotActionsPanel";
|
||||
|
||||
interface WizardMonitoringPanelProps {
|
||||
rosConnected: boolean;
|
||||
@@ -33,6 +36,14 @@ interface WizardMonitoringPanelProps {
|
||||
actionId: string,
|
||||
parameters: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
onSetAutonomousLife?: (enabled: boolean) => Promise<boolean | void>;
|
||||
onExecuteRobotAction?: (
|
||||
pluginName: string,
|
||||
actionId: string,
|
||||
parameters: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
studyId?: string;
|
||||
trialId?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
@@ -44,8 +55,28 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
||||
connectRos,
|
||||
disconnectRos,
|
||||
executeRosAction,
|
||||
onSetAutonomousLife,
|
||||
onExecuteRobotAction,
|
||||
studyId,
|
||||
trialId,
|
||||
readOnly = false,
|
||||
}: WizardMonitoringPanelProps) {
|
||||
const [autonomousLife, setAutonomousLife] = React.useState(true);
|
||||
|
||||
const handleAutonomousLifeChange = React.useCallback(async (checked: boolean) => {
|
||||
setAutonomousLife(checked); // Optimistic update
|
||||
if (onSetAutonomousLife) {
|
||||
try {
|
||||
const result = await onSetAutonomousLife(checked);
|
||||
if (result === false) {
|
||||
throw new Error("Service unavailable");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to set autonomous life:", error);
|
||||
setAutonomousLife(!checked); // Revert on failure
|
||||
}
|
||||
}
|
||||
}, [onSetAutonomousLife]);
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2 p-2">
|
||||
{/* Camera View - Always Visible */}
|
||||
@@ -166,6 +197,35 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Autonomous Life Toggle */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="autonomous-life" className="text-xs font-normal text-muted-foreground">Autonomous Life</Label>
|
||||
<Switch
|
||||
id="tour-wizard-autonomous"
|
||||
checked={!!autonomousLife}
|
||||
onCheckedChange={handleAutonomousLifeChange}
|
||||
disabled={!rosConnected || readOnly}
|
||||
className="scale-75"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Robot Actions Panel */}
|
||||
{studyId && trialId && onExecuteRobotAction ? (
|
||||
<div className={readOnly ? "pointer-events-none opacity-50" : ""}>
|
||||
<RobotActionsPanel
|
||||
studyId={studyId}
|
||||
trialId={trialId}
|
||||
onExecuteAction={onExecuteRobotAction}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Movement Controls */}
|
||||
{rosConnected && (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||
import { HorizontalTimeline } from "~/components/trials/timeline/HorizontalTimeline";
|
||||
|
||||
|
||||
interface TrialEvent {
|
||||
type: string;
|
||||
@@ -31,7 +31,7 @@ interface WizardObservationPaneProps {
|
||||
) => Promise<void>;
|
||||
isSubmitting?: boolean;
|
||||
readOnly?: boolean;
|
||||
activeTab?: "notes" | "timeline";
|
||||
|
||||
}
|
||||
|
||||
export function WizardObservationPane({
|
||||
@@ -39,7 +39,6 @@ export function WizardObservationPane({
|
||||
isSubmitting = false,
|
||||
trialEvents = [],
|
||||
readOnly = false,
|
||||
activeTab = "notes",
|
||||
}: WizardObservationPaneProps & { trialEvents?: TrialEvent[] }) {
|
||||
const [note, setNote] = useState("");
|
||||
const [category, setCategory] = useState("observation");
|
||||
@@ -71,7 +70,7 @@ export function WizardObservationPane({
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background">
|
||||
<div className={`flex-1 flex flex-col p-4 m-0 ${activeTab !== "notes" ? "hidden" : ""}`}>
|
||||
<div className="flex-1 flex flex-col p-4 m-0">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Textarea
|
||||
placeholder={readOnly ? "Session is read-only" : "Type your observation here..."}
|
||||
@@ -142,10 +141,6 @@ export function WizardObservationPane({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex-1 m-0 min-h-0 p-4 ${activeTab !== "timeline" ? "hidden" : ""}`}>
|
||||
<HorizontalTimeline events={trialEvents} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user