Refactor components and use in scenarios pages

This commit is contained in:
akhisud3195 2025-02-19 17:24:04 +05:30
parent 0c6837b248
commit 15122698a0
12 changed files with 953 additions and 334 deletions

View file

@ -19,8 +19,13 @@ interface EditableFieldProps {
className?: string;
validate?: (value: string) => { valid: boolean; errorMessage?: string };
light?: boolean;
<<<<<<< HEAD
mentions?: boolean;
mentionsAtValues?: Match[];
=======
showSaveButton?: boolean;
error?: string | null;
>>>>>>> 0ddd390 (Refactor components and use in scenarios pages)
}
export function EditableField({
@ -36,6 +41,8 @@ export function EditableField({
light = false,
mentions = false,
mentionsAtValues = [],
showSaveButton = multiline,
error,
}: EditableFieldProps) {
const [isEditing, setIsEditing] = useState(false);
const [localValue, setLocalValue] = useState(value);
@ -95,9 +102,9 @@ export function EditableField({
return (
<div ref={ref} className={clsx("flex flex-col gap-1", className)}>
{(label || isEditing && multiline) && <div className="flex items-center gap-2 justify-between">
{(label || isEditing && showSaveButton) && <div className="flex items-center gap-2 justify-between">
{label && <Label label={label} />}
{isEditing && multiline && <div className="flex items-center gap-2">
{isEditing && showSaveButton && <div className="flex items-center gap-2">
<Button
size="sm"
variant="light"
@ -156,12 +163,19 @@ export function EditableField({
<>
{markdown && <div className="max-h-[420px] overflow-y-auto text-gray-400 italic">
<MarkdownContent content={placeholder} atValues={mentionsAtValues} />
{markdown && <div className="max-h-[420px] overflow-y-auto text-gray-400">
<MarkdownContent content={placeholder} />
</div>}
{!markdown && <span className="text-gray-400 italic">{placeholder}</span>}
{!markdown && <span className="text-gray-400">{placeholder}</span>}
</>
)}
</div>
)}
{error && (
<div className="text-xs text-red-500 mt-1">
{error}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,18 @@
import { Divider } from "@nextui-org/react";
export function FormSection({
children,
className = "",
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<>
<div className={`flex flex-col gap-4 items-start ${className}`}>
{children}
</div>
<Divider />
</>
);
}

View file

@ -0,0 +1,82 @@
import clsx from "clsx";
import { InfoIcon } from "lucide-react";
import { Tooltip } from "@nextui-org/react";
export function ActionButton({
icon = null,
children,
onClick = undefined,
disabled = false,
primary = false,
}: {
icon?: React.ReactNode;
children: React.ReactNode;
onClick?: () => void | undefined;
disabled?: boolean;
primary?: boolean;
}) {
const onClickProp = onClick ? { onClick } : {};
return <button
disabled={disabled}
className={clsx("rounded-md text-xs flex items-center gap-1 disabled:text-gray-300 hover:text-gray-600", {
"text-blue-600": primary,
"text-gray-400": !primary,
})}
{...onClickProp}
>
{icon}
{children}
</button>;
}
export function StructuredPanel({
title,
actions = null,
children,
fancy = false,
tooltip = null,
}: {
title: React.ReactNode;
actions?: React.ReactNode[] | null;
children: React.ReactNode;
fancy?: boolean;
tooltip?: string | null;
}) {
return <div className={clsx("h-full flex flex-col overflow-auto rounded-md p-1", {
"bg-gray-100": !fancy,
"bg-blue-100": fancy,
})}>
<div className="shrink-0 flex justify-between items-center gap-2 px-2 py-1 rounded-t-sm">
<div className="flex items-center gap-1">
<div className={clsx("text-xs font-semibold uppercase", {
"text-gray-400": !fancy,
"text-blue-500": fancy,
})}>
{title}
</div>
{tooltip && (
<Tooltip
content={tooltip}
placement="right"
className="cursor-help"
>
<InfoIcon size={12} className={clsx({
"text-gray-400": !fancy,
"text-blue-500": fancy,
})} />
</Tooltip>
)}
</div>
{!actions && <div className="w-4 h-4" />}
{actions && <div className={clsx("rounded-md hover:text-gray-800 px-2 text-sm flex items-center gap-2", {
"text-blue-600": fancy,
"text-gray-400": !fancy,
})}>
{actions}
</div>}
</div>
<div className="grow bg-white rounded-md overflow-auto flex flex-col justify-start p-2">
{children}
</div>
</div>;
}

View file

@ -2,7 +2,7 @@
import { useState, useEffect, useCallback } from 'react';
import { PlusIcon, PencilIcon, XMarkIcon, EllipsisVerticalIcon, TrashIcon, ChevronRightIcon, PlayIcon, ChevronDownIcon, ChevronLeftIcon } from '@heroicons/react/24/outline';
import { useParams, useRouter } from 'next/navigation';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import {
getScenarios,
createScenario,
@ -22,8 +22,15 @@ import { Scenario, SimulationRun, SimulationResult } from "../../../lib/types/te
import { Workflow } from "../../../lib/types/workflow_types";
import { z } from 'zod';
import { SimulationResultCard, ScenarioResultCard } from './components/RunComponents';
import { ScenarioViewer } from './components/ScenarioComponents';
import { ScenarioList, ScenarioViewer } from './components/ScenarioComponents';
import { fetchWorkflow } from '../../../actions/workflow_actions';
import { StructuredPanel, ActionButton } from "../../../lib/components/structured-panel";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "../../../../components/ui/resizable"
import { Pagination } from "../../../lib/components/pagination";
type ScenarioType = WithStringId<z.infer<typeof Scenario>>;
type SimulationRunType = WithStringId<z.infer<typeof SimulationRun>>;
@ -65,6 +72,7 @@ const dummySimulator = async (scenario: ScenarioType, runId: string, projectId:
export default function SimulationApp() {
const { projectId } = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const [scenarios, setScenarios] = useState<ScenarioType[]>([]);
const [selectedScenario, setSelectedScenario] = useState<ScenarioType | null>(null);
const [isEditing, setIsEditing] = useState(false);
@ -79,7 +87,7 @@ export default function SimulationApp() {
const [allRunResults, setAllRunResults] = useState<Record<string, SimulationResultType[]>>({});
const [workflowVersions, setWorkflowVersions] = useState<Record<string, WithStringId<z.infer<typeof Workflow>>>>({});
const [menuOpenId, setMenuOpenIdState] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [currentPage, setCurrentPage] = useState(Number(searchParams.get('page')) || 1);
const runsPerPage = 10;
const setMenuOpenId = useCallback((id: string | null) => {
@ -367,88 +375,34 @@ export default function SimulationApp() {
}
};
// Add this effect to update currentPage when URL changes
useEffect(() => {
const page = Number(searchParams.get('page')) || 1;
setCurrentPage(page);
}, [searchParams]);
const indexOfLastRun = currentPage * runsPerPage;
const indexOfFirstRun = indexOfLastRun - runsPerPage;
const currentRuns = runs.slice(indexOfFirstRun, indexOfLastRun);
const totalPages = Math.ceil(runs.length / runsPerPage);
return (
<div className="flex h-screen">
{/* Left sidebar */}
<div className="w-64 border-r border-gray-200 p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Scenarios</h2>
<div className="flex gap-2">
<button
onClick={createNewScenario}
className="p-2 rounded-full hover:bg-gray-100"
title="New Scenario"
>
<PlusIcon className="h-5 w-5" />
</button>
</div>
</div>
<div className="space-y-2">
{scenarios.map(scenario => (
<div
key={scenario._id}
className={`p-2 rounded flex justify-between items-center ${
selectedScenario?._id === scenario._id
? 'bg-blue-100'
: 'hover:bg-gray-100'
}`}
>
<div
onClick={() => setSelectedScenario(scenario)}
className="cursor-pointer flex-grow"
>
{scenario.name}
</div>
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation();
setMenuOpenScenarioId(menuOpenScenarioId === scenario._id ? null : scenario._id);
}}
className="p-1 rounded-full hover:bg-gray-200"
>
<EllipsisVerticalIcon className="h-5 w-5 text-gray-600" />
</button>
{menuOpenScenarioId === scenario._id && (
<div className="absolute right-0 mt-1 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-10">
<div className="py-1">
<button
onClick={(e) => {
e.stopPropagation();
runSingleScenario(scenario);
}}
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 w-full"
>
<PlayIcon className="h-4 w-4 mr-2" />
Run Scenario
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteScenario(scenario._id);
}}
className="flex items-center px-4 py-2 text-sm text-red-600 hover:bg-gray-100 w-full"
>
<TrashIcon className="h-4 w-4 mr-2" />
Delete Scenario
</button>
</div>
</div>
)}
</div>
</div>
))}
</div>
</div>
{/* Main content */}
<div className="flex-1 p-6 overflow-auto">
<ResizablePanelGroup direction="horizontal" className="h-screen gap-1">
<ResizablePanel minSize={10} defaultSize={15}>
<ScenarioList
scenarios={scenarios}
selectedId={selectedScenario?._id ?? null}
onSelect={(id) => setSelectedScenario(scenarios.find(s => s._id === id) ?? null)}
onAdd={createNewScenario}
onRunScenario={(id) => {
const scenario = scenarios.find(s => s._id === id);
if (scenario) runSingleScenario(scenario);
}}
onDeleteScenario={(id) => handleDeleteScenario(id)}
/>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel minSize={20} defaultSize={85} className="overflow-auto">
{selectedScenario ? (
<ScenarioViewer
scenario={selectedScenario}
@ -456,81 +410,56 @@ export default function SimulationApp() {
onClose={handleCloseScenario}
/>
) : (
<div className="space-y-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Simulation Runs</h1>
<button
<StructuredPanel
title="SIMULATION RUNS"
tooltip="Run and view simulations"
actions={[
<ActionButton
key="run-all"
onClick={runAllScenarios}
disabled={isRunning || scenarios.length === 0}
className={`px-4 py-2 rounded-md text-white ${
isRunning || scenarios.length === 0
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
}`}
disabled={isRunning}
icon={<PlayIcon className="w-4 h-4" />}
primary
>
{isRunning ? 'Running...' : 'Run All Scenarios'}
</button>
Run All Scenarios
</ActionButton>
]}
>
<div className="p-6">
{/* Runs list */}
{isLoadingRuns ? (
<div>Loading runs...</div>
) : (
<div className="space-y-4">
{currentRuns.map((run) => (
<SimulationResultCard
key={run._id}
run={run}
results={allRunResults[run._id] || []}
scenarios={scenarios}
workflow={workflowVersions[run.workflowId]}
onCancelRun={handleCancelRun}
onDeleteRun={handleDeleteRun}
menuOpenId={menuOpenId}
setMenuOpenId={setMenuOpenId}
/>
))}
</div>
)}
{/* Pagination */}
{runs.length > runsPerPage && (
<div className="flex justify-center mt-4">
<Pagination
total={totalPages}
page={currentPage}
/>
</div>
)}
</div>
{isLoadingRuns ? (
<div className="text-center py-4">Loading runs...</div>
) : runs.length === 0 ? (
<div className="text-center py-4 text-gray-500">No simulation runs yet</div>
) : (
<>
<div className="space-y-4">
{currentRuns.map((run) => (
<SimulationResultCard
key={run._id}
run={run}
results={allRunResults[run._id] || []}
scenarios={scenarios}
workflow={workflowVersions[run.workflowId]}
onCancelRun={handleCancelRun}
onDeleteRun={handleDeleteRun}
menuOpenId={menuOpenId}
setMenuOpenId={setMenuOpenId}
/>
))}
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex justify-center items-center space-x-4 mt-6">
<button
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
className={`p-2 rounded-full ${
currentPage === 1
? 'text-gray-400 cursor-not-allowed'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<ChevronLeftIcon className="h-5 w-5" />
</button>
<span className="text-sm text-gray-600">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages}
className={`p-2 rounded-full ${
currentPage === totalPages
? 'text-gray-400 cursor-not-allowed'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<ChevronRightIcon className="h-5 w-5" />
</button>
</div>
)}
</>
)}
</div>
</StructuredPanel>
)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}

View file

@ -116,7 +116,7 @@ export const SimulationResultCard = ({ run, results, scenarios, workflow, onCanc
) : (
<ChevronRightIcon className="h-5 w-5 text-gray-400" />
)}
<div className="text-lg font-semibold">
<div className="text-sm truncate">
{formatMainTitle(run.startedAt)}
</div>
</div>

View file

@ -1,10 +1,15 @@
'use client';
import { useState, useEffect, useCallback, ChangeEvent } from 'react';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { useState, useEffect, useCallback } from 'react';
import { Save, EllipsisVerticalIcon, PlayIcon, TrashIcon, X } from "lucide-react";
import { WithStringId } from '../../../../lib/types/types';
import { Scenario } from "../../../../lib/types/testing_types";
import { z } from 'zod';
import { EditableField } from '../../../../lib/components/editable-field';
import { FormSection } from '../../../../lib/components/form-section';
import { StructuredPanel, ActionButton } from "../../../../lib/components/structured-panel";
import clsx from "clsx";
import { Dropdown, DropdownItem, DropdownTrigger, DropdownMenu } from "@nextui-org/react";
type ScenarioType = WithStringId<z.infer<typeof Scenario>>;
@ -17,17 +22,17 @@ interface ScenarioViewerProps {
export function ScenarioViewer({ scenario, onSave, onClose }: ScenarioViewerProps) {
const [editedScenario, setEditedScenario] = useState<ScenarioType>(scenario);
const [isDirty, setIsDirty] = useState(false);
const [nameError, setNameError] = useState<string | null>(null);
// Reset state when scenario changes
useEffect(() => {
setEditedScenario(scenario);
setIsDirty(false);
}, [scenario]);
const handleChange = useCallback((field: keyof ScenarioType, event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
event.preventDefault();
const value = event.target.value;
const handleChange = useCallback((field: keyof ScenarioType, value: string) => {
if (field === 'name') {
setNameError(value.trim() ? null : 'Name is required');
}
setEditedScenario(prev => ({
...prev,
[field]: value,
@ -36,95 +41,208 @@ export function ScenarioViewer({ scenario, onSave, onClose }: ScenarioViewerProp
}, []);
const handleSave = useCallback(() => {
if (!editedScenario.name.trim()) {
setNameError('Name is required');
return;
}
onSave(editedScenario);
onClose();
}, [editedScenario, onSave, onClose]);
const adjustTextareaHeight = useCallback((element: HTMLTextAreaElement) => {
element.style.height = 'auto';
element.style.height = `${element.scrollHeight}px`;
}, []);
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Scenario Details</h1>
<div className="flex gap-2">
{isDirty && (
<button
onClick={handleSave}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
Save
</button>
)}
<button
onClick={onClose}
className="p-2 rounded-full hover:bg-gray-100"
title="Close"
<StructuredPanel
title="SCENARIO DETAILS"
actions={[
isDirty && (
<ActionButton
key="save"
onClick={handleSave}
icon={<Save className="w-4 h-4" />}
primary
>
<XMarkIcon className="h-5 w-5 text-gray-600" />
</button>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col">
<div className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-4">NAME</div>
<input
type="text"
Save
</ActionButton>
),
<ActionButton
key="close"
onClick={onClose}
icon={<X className="w-4 h-4" />}
>
Close
</ActionButton>
].filter(Boolean)}
>
<div className="flex flex-col gap-4 p-6 w-full">
<FormSection>
<EditableField
label="NAME"
value={editedScenario.name}
onChange={(e) => handleChange('name', e)}
className="text-base border border-gray-200 rounded px-2 py-1 hover:border-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
autoComplete="off"
spellCheck="false"
onChange={(value) => handleChange('name', value)}
multiline={false}
className="w-full"
showSaveButton={false}
placeholder="Enter an identifiable scenario name"
error={nameError}
/>
</div>
</FormSection>
<div className="border-t border-gray-200 my-4"></div>
<div className="flex flex-col">
<div className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-4">DESCRIPTION</div>
<textarea
<FormSection>
<EditableField
label="DESCRIPTION"
value={editedScenario.description}
onChange={(e) => handleChange('description', e)}
onInput={(e) => adjustTextareaHeight(e.target as HTMLTextAreaElement)}
className="text-base border border-gray-200 rounded px-2 py-1 hover:border-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 min-h-[24px] resize-none"
style={{ height: 'auto', minHeight: '24px' }}
autoComplete="off"
spellCheck="false"
onChange={(value) => handleChange('description', value)}
multiline={true}
className="w-full"
showSaveButton={false}
placeholder="Describe the user scenario to be simulated"
/>
</div>
</FormSection>
<div className="border-t border-gray-200 my-4"></div>
<div className="flex flex-col">
<div className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-4">CRITERIA</div>
<textarea
<FormSection>
<EditableField
label="CRITERIA"
value={editedScenario.criteria}
onChange={(e) => handleChange('criteria', e)}
onInput={(e) => adjustTextareaHeight(e.target as HTMLTextAreaElement)}
className="text-base border border-gray-200 rounded px-2 py-1 hover:border-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 min-h-[24px] resize-none"
style={{ height: 'auto', minHeight: '24px' }}
autoComplete="off"
spellCheck="false"
onChange={(value) => handleChange('criteria', value)}
multiline={true}
className="w-full"
showSaveButton={false}
placeholder="Enter success criteria for this scenario to pass in a simulation"
/>
</div>
<div className="border-t border-gray-200 my-4"></div>
</FormSection>
<div className="flex flex-col">
<div className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-4">CONTEXT</div>
<textarea
<FormSection>
<EditableField
label="CONTEXT"
value={editedScenario.context}
onChange={(e) => handleChange('context', e)}
onInput={(e) => adjustTextareaHeight(e.target as HTMLTextAreaElement)}
className="text-base border border-gray-200 rounded px-2 py-1 hover:border-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 min-h-[24px] resize-none"
style={{ height: 'auto', minHeight: '24px' }}
autoComplete="off"
spellCheck="false"
onChange={(value) => handleChange('context', value)}
multiline={true}
className="w-full"
showSaveButton={false}
placeholder="Provide context about the user to the assistant at the start of chat"
/>
</div>
</FormSection>
</div>
</div>
</StructuredPanel>
);
}
function SectionHeader({ title, onAdd }: { title: string; onAdd: () => void }) {
return (
<div className="flex items-center justify-between px-2 py-1 mt-4 first:mt-0 border-b border-gray-200">
<div className="text-xs font-semibold text-gray-400 uppercase">{title}</div>
<ActionButton
icon={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 12h14m-7 7V5" />
</svg>}
onClick={onAdd}
>
Add
</ActionButton>
</div>
);
}
function ListItem({
name,
isSelected,
onClick,
rightElement
}: {
name: string;
isSelected: boolean;
onClick: () => void;
rightElement?: React.ReactNode;
}) {
return (
<button
onClick={onClick}
className={clsx("flex items-center justify-between rounded-md px-2 py-1", {
"bg-gray-100": isSelected,
"hover:bg-gray-50": !isSelected,
})}
>
<div className="truncate text-sm">{name}</div>
{rightElement}
</button>
);
}
function ScenarioDropdown({
name,
onRun,
onDelete,
}: {
name: string;
onRun: () => void;
onDelete: () => void;
}) {
return (
<Dropdown>
<DropdownTrigger>
<EllipsisVerticalIcon size={16} />
</DropdownTrigger>
<DropdownMenu
onAction={(key) => {
if (key === 'run') onRun();
if (key === 'delete') onDelete();
}}
>
<DropdownItem
key="run"
startContent={<PlayIcon className="w-4 h-4" />}
>
Run scenario
</DropdownItem>
<DropdownItem
key="delete"
className="text-danger"
startContent={<TrashIcon className="w-4 h-4" />}
>
Delete
</DropdownItem>
</DropdownMenu>
</Dropdown>
);
}
export function ScenarioList({
scenarios,
selectedId,
onSelect,
onAdd,
onRunScenario,
onDeleteScenario,
}: {
scenarios: ScenarioType[];
selectedId: string | null;
onSelect: (id: string) => void;
onAdd: () => void;
onRunScenario: (id: string) => void;
onDeleteScenario: (id: string) => void;
}) {
return (
<StructuredPanel
title="TESTS"
tooltip="Browse and manage your test scenarios"
>
<div className="overflow-auto flex flex-col gap-1 justify-start">
<SectionHeader title="Scenarios" onAdd={onAdd} />
{scenarios.map((scenario) => (
<ListItem
key={scenario._id}
name={scenario.name}
isSelected={selectedId === scenario._id}
onClick={() => onSelect(scenario._id)}
rightElement={
<ScenarioDropdown
name={scenario.name}
onRun={() => onRunScenario(scenario._id)}
onDelete={() => onDeleteScenario(scenario._id)}
/>
}
/>
))}
</div>
</StructuredPanel>
);
}

View file

@ -0,0 +1,536 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { PlusIcon, PencilIcon, XMarkIcon, EllipsisVerticalIcon, TrashIcon, ChevronRightIcon, PlayIcon, ChevronDownIcon, ChevronLeftIcon } from '@heroicons/react/24/outline';
import { useParams, useRouter } from 'next/navigation';
import {
getScenarios,
createScenario,
updateScenario,
deleteScenario,
getRuns,
getRun,
getRunResults,
createRun,
createRunResult,
updateRunStatus,
createAggregateResult,
deleteRun,
} from '../../../actions/simulation_actions';
import { type WithStringId } from '../../../lib/types/types';
import { Scenario, SimulationRun, SimulationResult } from "../../../lib/types/testing_types";
import { Workflow } from "../../../lib/types/workflow_types";
import { z } from 'zod';
import { SimulationResultCard, ScenarioResultCard } from './components/RunComponents';
import { ScenarioViewer } from './components/ScenarioComponents';
import { fetchWorkflow } from '../../../actions/workflow_actions';
type ScenarioType = WithStringId<z.infer<typeof Scenario>>;
type SimulationRunType = WithStringId<z.infer<typeof SimulationRun>>;
type SimulationResultType = WithStringId<z.infer<typeof SimulationResult>>;
type SimulationReport = {
totalScenarios: number;
passedScenarios: number;
failedScenarios: number;
results: z.infer<typeof SimulationResult>[];
timestamp: Date;
};
const dummySimulator = async (scenario: ScenarioType, runId: string, projectId: string): Promise<z.infer<typeof SimulationResult>> => {
await new Promise(resolve => setTimeout(resolve, 500));
const passed = Math.random() > 0.5;
const result: z.infer<typeof SimulationResult> = {
projectId: projectId,
runId: runId,
scenarioId: scenario._id,
result: passed ? 'pass' : 'fail' as const,
details: passed
? "The bot successfully completed the conversation"
: "The bot could not handle the conversation",
};
await createRunResult(
projectId,
runId,
scenario._id,
result.result,
result.details
);
return result;
};
export default function SimulationApp() {
const { projectId } = useParams();
const router = useRouter();
const [scenarios, setScenarios] = useState<ScenarioType[]>([]);
const [selectedScenario, setSelectedScenario] = useState<ScenarioType | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [menuOpenScenarioId, setMenuOpenScenarioId] = useState<string | null>(null);
const [isRunning, setIsRunning] = useState(false);
const [simulationReport, setSimulationReport] = useState<SimulationReport | null>(null);
const [expandedResults, setExpandedResults] = useState<Set<string>>(new Set());
const [runs, setRuns] = useState<SimulationRunType[]>([]);
const [activeRun, setActiveRun] = useState<SimulationRunType | null>(null);
const [runResults, setRunResults] = useState<SimulationResultType[]>([]);
const [isLoadingRuns, setIsLoadingRuns] = useState(true);
const [allRunResults, setAllRunResults] = useState<Record<string, SimulationResultType[]>>({});
const [workflowVersions, setWorkflowVersions] = useState<Record<string, WithStringId<z.infer<typeof Workflow>>>>({});
const [menuOpenId, setMenuOpenIdState] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const runsPerPage = 10;
const setMenuOpenId = useCallback((id: string | null) => {
setMenuOpenIdState(id);
}, []);
// Load scenarios on mount
useEffect(() => {
if (!projectId) return;
getScenarios(projectId as string).then(setScenarios);
}, [projectId]);
useEffect(() => {
if (menuOpenScenarioId) {
const closeMenu = () => setMenuOpenScenarioId(null);
window.addEventListener('click', closeMenu);
return () => window.removeEventListener('click', closeMenu);
}
}, [menuOpenScenarioId]);
// Modify the fetchRuns function to also fetch results
const fetchRuns = useCallback(async () => {
if (!projectId) return;
setIsLoadingRuns(true);
try {
const runsData = await getRuns(projectId as string);
setRuns(runsData);
// Fetch results for all runs
const resultsPromises = runsData.map(run =>
getRunResults(projectId as string, run._id)
);
const allResults = await Promise.all(resultsPromises);
// Create a map of run ID to results
const resultsMap = runsData.reduce((acc, run, index) => ({
...acc,
[run._id]: allResults[index]
}), {});
setAllRunResults(resultsMap);
} catch (error) {
console.error('Error fetching runs:', error);
} finally {
setIsLoadingRuns(false);
}
}, [projectId]);
// Update the useEffect hooks to include fetchRuns
useEffect(() => {
if (!projectId) return;
fetchRuns();
}, [projectId, fetchRuns]);
useEffect(() => {
if (!projectId || !activeRun || activeRun.status === 'completed' || activeRun.status === 'cancelled') return;
const interval = setInterval(async () => {
try {
const updatedRun = await getRun(projectId as string, activeRun._id);
setActiveRun(updatedRun);
if (updatedRun.status === 'completed') {
const results = await getRunResults(projectId as string, activeRun._id);
setRunResults(results);
fetchRuns(); // Refresh the runs list
}
} catch (error) {
console.error('Error polling run status:', error);
}
}, 2000);
return () => clearInterval(interval);
}, [activeRun, projectId, fetchRuns]);
const createNewScenario = async () => {
if (!projectId) return;
const newScenarioId = await createScenario(
projectId as string,
'New Scenario',
''
);
// Refresh scenarios list
const updatedScenarios = await getScenarios(projectId as string);
setScenarios(updatedScenarios);
const newScenario = updatedScenarios.find(s => s._id === newScenarioId);
if (newScenario) {
setSelectedScenario(newScenario);
setIsEditing(true);
}
};
const handleUpdateScenario = async (updatedScenario: ScenarioType) => {
if (!projectId) return;
// First verify the scenario exists and get its current state
const currentScenarios = await getScenarios(projectId as string);
const existingScenario = currentScenarios.find(s => s._id === updatedScenario._id);
if (!existingScenario) {
console.error('Scenario not found');
return;
}
// Only update the specific fields that have changed
await updateScenario(
projectId as string,
updatedScenario._id,
{
name: updatedScenario.name,
description: updatedScenario.description,
criteria: updatedScenario.criteria,
context: updatedScenario.context,
}
);
// Just refresh the scenarios list without setting selected scenario
const updatedScenarios = await getScenarios(projectId as string);
setScenarios(updatedScenarios);
setIsEditing(false);
};
const handleCloseScenario = () => {
setSelectedScenario(null);
setIsEditing(false);
};
const handleDeleteScenario = async (scenarioId: string) => {
if (!projectId) return;
await deleteScenario(projectId as string, scenarioId);
const updatedScenarios = await getScenarios(projectId as string);
setScenarios(updatedScenarios);
if (selectedScenario?._id === scenarioId) {
setSelectedScenario(null);
setIsEditing(false);
}
setMenuOpenScenarioId(null);
};
const runAllScenarios = async () => {
if (!projectId) return;
setIsRunning(true);
setSimulationReport(null);
try {
// Get workflowId from localStorage
const workflowId = localStorage.getItem(`lastWorkflowId_${projectId}`);
if (!workflowId) {
throw new Error('No workflow selected. Please select a workflow first.');
}
// First verify the workflow exists before creating the run
let workflow;
try {
workflow = await fetchWorkflow(projectId as string, workflowId);
} catch (error) {
// If workflow doesn't exist, clear localStorage and throw error
localStorage.removeItem(`lastWorkflowId_${projectId}`);
throw new Error('Selected workflow no longer exists. Please select a new workflow.');
}
const newRun = await createRun(
projectId as string,
scenarios.map(s => s._id),
workflowId
);
setActiveRun(newRun);
// Store workflow version
setWorkflowVersions(prev => ({
...prev,
[workflowId]: workflow
}));
const shouldMock = process.env.NEXT_PUBLIC_MOCK_SIMULATION_RESULTS === 'true';
if (shouldMock) {
console.log('Using mock simulation...');
await updateRunStatus(projectId as string, newRun._id, 'running');
// Run all scenarios and collect results
const mockResults = await Promise.all(
scenarios.map(scenario =>
dummySimulator(scenario, newRun._id, projectId as string)
)
);
// Calculate and store aggregate results before marking as complete
const total = scenarios.length;
const pass = mockResults.filter(r => r.result === 'pass').length;
const fail = mockResults.filter(r => r.result === 'fail').length;
await createAggregateResult(
projectId as string,
newRun._id,
total,
pass,
fail
);
await updateRunStatus(
projectId as string,
newRun._id,
'completed',
new Date().toISOString()
);
const results = await getRunResults(projectId as string, newRun._id);
setRunResults(results);
const updatedRun = await getRun(projectId as string, newRun._id);
setActiveRun(updatedRun);
}
await fetchRuns();
} catch (error) {
console.error('Error starting scenarios:', error);
alert(error instanceof Error ? error.message : 'An error occurred while starting scenarios');
} finally {
setIsRunning(false);
}
};
const runSingleScenario = (scenario: ScenarioType) => {
// Store scenario ID in localStorage instead of URL parameter
localStorage.setItem('pendingScenarioId', scenario._id);
// Navigate to the playground without query parameter
router.push(`/projects/${projectId}/workflow`);
setMenuOpenScenarioId(null);
};
// Update the workflow versions fetching effect
useEffect(() => {
if (!projectId || !runs.length) return;
const fetchWorkflowVersions = async () => {
const workflowIds = Array.from(new Set(runs.map(run => run.workflowId)));
const versions: Record<string, WithStringId<z.infer<typeof Workflow>>> = {};
for (const workflowId of workflowIds) {
try {
const workflow = await fetchWorkflow(projectId as string, workflowId);
versions[workflowId] = workflow;
} catch (error) {
console.error(`Error fetching workflow ${workflowId}:`, error);
// Add a placeholder for deleted/invalid workflows
versions[workflowId] = {
_id: workflowId,
name: "Deleted/Invalid Workflow",
projectId: projectId as string,
agents: [],
prompts: [],
tools: [],
startAgent: "",
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
};
}
}
setWorkflowVersions(versions);
};
fetchWorkflowVersions();
}, [projectId, runs]);
const handleCancelRun = async (runId: string) => {
if (!projectId) return;
try {
await updateRunStatus(projectId as string, runId, 'cancelled');
await fetchRuns(); // Refresh the runs list
} catch (error) {
console.error('Error cancelling run:', error);
}
};
const handleDeleteRun = async (runId: string) => {
if (!projectId) return;
try {
await deleteRun(projectId as string, runId);
await fetchRuns(); // Refresh the runs list
} catch (error) {
console.error('Error deleting run:', error);
}
};
const indexOfLastRun = currentPage * runsPerPage;
const indexOfFirstRun = indexOfLastRun - runsPerPage;
const currentRuns = runs.slice(indexOfFirstRun, indexOfLastRun);
const totalPages = Math.ceil(runs.length / runsPerPage);
return (
<div className="flex h-screen">
{/* Left sidebar */}
<div className="w-64 border-r border-gray-200 p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Scenarios</h2>
<div className="flex gap-2">
<button
onClick={createNewScenario}
className="p-2 rounded-full hover:bg-gray-100"
title="New Scenario"
>
<PlusIcon className="h-5 w-5" />
</button>
</div>
</div>
<div className="space-y-2">
{scenarios.map(scenario => (
<div
key={scenario._id}
className={`p-2 rounded flex justify-between items-center ${
selectedScenario?._id === scenario._id
? 'bg-blue-100'
: 'hover:bg-gray-100'
}`}
>
<div
onClick={() => setSelectedScenario(scenario)}
className="cursor-pointer flex-grow"
>
{scenario.name}
</div>
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation();
setMenuOpenScenarioId(menuOpenScenarioId === scenario._id ? null : scenario._id);
}}
className="p-1 rounded-full hover:bg-gray-200"
>
<EllipsisVerticalIcon className="h-5 w-5 text-gray-600" />
</button>
{menuOpenScenarioId === scenario._id && (
<div className="absolute right-0 mt-1 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-10">
<div className="py-1">
<button
onClick={(e) => {
e.stopPropagation();
runSingleScenario(scenario);
}}
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 w-full"
>
<PlayIcon className="h-4 w-4 mr-2" />
Run Scenario
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteScenario(scenario._id);
}}
className="flex items-center px-4 py-2 text-sm text-red-600 hover:bg-gray-100 w-full"
>
<TrashIcon className="h-4 w-4 mr-2" />
Delete Scenario
</button>
</div>
</div>
)}
</div>
</div>
))}
</div>
</div>
{/* Main content */}
<div className="flex-1 p-6 overflow-auto">
{selectedScenario ? (
<ScenarioViewer
scenario={selectedScenario}
onSave={handleUpdateScenario}
onClose={handleCloseScenario}
/>
) : (
<div className="space-y-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Simulation Runs</h1>
<button
onClick={runAllScenarios}
disabled={isRunning || scenarios.length === 0}
className={`px-4 py-2 rounded-md text-white ${
isRunning || scenarios.length === 0
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{isRunning ? 'Running...' : 'Run All Scenarios'}
</button>
</div>
{isLoadingRuns ? (
<div className="text-center py-4">Loading runs...</div>
) : runs.length === 0 ? (
<div className="text-center py-4 text-gray-500">No simulation runs yet</div>
) : (
<>
<div className="space-y-4">
{currentRuns.map((run) => (
<SimulationResultCard
key={run._id}
run={run}
results={allRunResults[run._id] || []}
scenarios={scenarios}
workflow={workflowVersions[run.workflowId]}
onCancelRun={handleCancelRun}
onDeleteRun={handleDeleteRun}
menuOpenId={menuOpenId}
setMenuOpenId={setMenuOpenId}
/>
))}
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex justify-center items-center space-x-4 mt-6">
<button
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
className={`p-2 rounded-full ${
currentPage === 1
? 'text-gray-400 cursor-not-allowed'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<ChevronLeftIcon className="h-5 w-5" />
</button>
<span className="text-sm text-gray-600">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages}
className={`p-2 rounded-full ${
currentPage === totalPages
? 'text-gray-400 cursor-not-allowed'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<ChevronRightIcon className="h-5 w-5" />
</button>
</div>
)}
</>
)}
</div>
)}
</div>
</div>
);
}

View file

@ -1,6 +1,6 @@
'use client';
import { Button, Textarea } from "@nextui-org/react";
import { ActionButton, Pane } from "./pane";
import { ActionButton, StructuredPanel } from "../../../lib/components/structured-panel";
import { useEffect, useRef, useState, createContext, useContext, useCallback } from "react";
import { CopilotChatContext } from "../../../lib/types/copilot_types";
import { CopilotMessage } from "../../../lib/types/copilot_types";
@ -529,7 +529,7 @@ export function Copilot({
setResponseError: (error: string | null) => void;
}) {
return (
<Pane
<StructuredPanel
fancy
title="COPILOT"
tooltip="Get AI assistance for creating and improving your multi-agent system"
@ -562,6 +562,6 @@ export function Copilot({
responseError={responseError}
setResponseError={setResponseError}
/>
</Pane>
</StructuredPanel>
);
}

View file

@ -4,7 +4,7 @@ import { WorkflowPrompt } from "../../../lib/types/workflow_types";
import { WorkflowAgent } from "../../../lib/types/workflow_types";
import { Dropdown, DropdownItem, DropdownTrigger, DropdownMenu } from "@nextui-org/react";
import { useRef, useEffect } from "react";
import { ActionButton, Pane } from "./pane";
import { ActionButton, StructuredPanel } from "../../../lib/components/structured-panel";
import clsx from "clsx";
import { EllipsisVerticalIcon } from "lucide-react";
@ -105,7 +105,7 @@ export function EntityList({
}, [selectedEntity]);
return (
<Pane
<StructuredPanel
title="WORKFLOW"
tooltip="Browse and manage your agents, tools, and prompts in this sidebar"
>
@ -163,7 +163,7 @@ export function EntityList({
/>
))}
</div>
</Pane>
</StructuredPanel>
);
}

View file

@ -1,82 +1,7 @@
import clsx from "clsx";
import { InfoIcon } from "lucide-react";
import { Tooltip } from "@nextui-org/react";
import { StructuredPanel, ActionButton } from "../../../lib/components/structured-panel";
export function Pane({
title,
actions = null,
children,
fancy = false,
tooltip = null,
}: {
title: React.ReactNode;
actions?: React.ReactNode[] | null;
children: React.ReactNode;
fancy?: boolean;
tooltip?: string | null;
}) {
return <div className={clsx("h-full flex flex-col overflow-auto rounded-md p-1", {
"bg-gray-100": !fancy,
"bg-blue-100": fancy,
})}>
<div className="shrink-0 flex justify-between items-center gap-2 px-2 py-1 rounded-t-sm">
<div className="flex items-center gap-1">
<div className={clsx("text-xs font-semibold uppercase", {
"text-gray-400": !fancy,
"text-blue-500": fancy,
})}>
{title}
</div>
{tooltip && (
<Tooltip
content={tooltip}
placement="right"
className="cursor-help"
>
<InfoIcon size={12} className={clsx({
"text-gray-400": !fancy,
"text-blue-500": fancy,
})} />
</Tooltip>
)}
</div>
{!actions && <div className="w-4 h-4" />}
{actions && <div className={clsx("rounded-md hover:text-gray-800 px-2 text-sm flex items-center gap-2", {
"text-blue-600": fancy,
"text-gray-400": !fancy,
})}>
{actions}
</div>}
</div>
<div className="grow bg-white rounded-md overflow-auto flex flex-col justify-start p-2">
{children}
</div>
</div>;
}
// Re-export both components for backward compatibility
export const Pane = StructuredPanel;
export { ActionButton };
export function ActionButton({
icon = null,
children,
onClick = undefined,
disabled = false,
primary = false,
}: {
icon?: React.ReactNode;
children: React.ReactNode;
onClick?: () => void | undefined;
disabled?: boolean;
primary?: boolean;
}) {
const onClickProp = onClick ? { onClick } : {};
return <button
disabled={disabled}
className={clsx("rounded-md text-xs flex items-center gap-1 disabled:text-gray-300 hover:text-gray-600", {
"text-blue-600": primary,
"text-gray-400": !primary,
})}
{...onClickProp}
>
{icon}
{children}
</button>;
}
// TODO: Delete this file once all the files are updated to use StructuredPanel

View file

@ -2,8 +2,9 @@
import { WorkflowAgent, WorkflowPrompt, WorkflowTool } from "../../../lib/types/workflow_types";
import { Divider } from "@nextui-org/react";
import { z } from "zod";
import { ActionButton, Pane } from "./pane";
import { ActionButton, StructuredPanel } from "../../../lib/components/structured-panel";
import { EditableField } from "../../../lib/components/editable-field";
import { XIcon } from "@heroicons/react/24/outline";
export function PromptConfig({
prompt,
@ -48,13 +49,11 @@ export function PromptConfig({
});
}
return <Pane title={prompt.name} actions={[
return <StructuredPanel title={prompt.name} actions={[
<ActionButton
key="close"
onClick={handleClose}
icon={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M6 18 17.94 6M18 18 6.06 6" />
</svg>}
icon={<XIcon className="w-4 h-4" />}
>
Close
</ActionButton>
@ -104,5 +103,5 @@ export function PromptConfig({
/>
</div>
</div>
</Pane>;
</StructuredPanel>;
}

View file

@ -2,7 +2,7 @@
import { WorkflowTool } from "../../../lib/types/workflow_types";
import { Accordion, AccordionItem, Button, Checkbox, Select, SelectItem, Switch, RadioGroup, Radio } from "@nextui-org/react";
import { z } from "zod";
import { ActionButton, Pane } from "./pane";
import { ActionButton, StructuredPanel } from "../../../lib/components/structured-panel";
import { EditableField } from "../../../lib/components/editable-field";
import { Divider } from "@nextui-org/react";
import { Label } from "../../../lib/components/label";
@ -31,7 +31,7 @@ export function ParameterConfig({
handleDelete: (name: string) => void,
handleRename: (oldName: string, newName: string) => void
}) {
return <Pane
return <StructuredPanel
title={param.name}
actions={[
<ActionButton
@ -106,7 +106,7 @@ export function ParameterConfig({
Required
</Checkbox>
</div>
</Pane>;
</StructuredPanel>;
}
export function ToolConfig({
@ -183,13 +183,11 @@ export function ToolConfig({
}
return (
<Pane title={tool.name} actions={[
<StructuredPanel title={tool.name} actions={[
<ActionButton
key="close"
onClick={handleClose}
icon={<svg className="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M6 18 17.94 6M18 18 6.06 6" />
</svg>}
icon={<XIcon className="w-4 h-4" />}
>
Close
</ActionButton>
@ -343,6 +341,6 @@ export function ToolConfig({
Add Parameter
</Button>
</div>
</Pane>
</StructuredPanel>
);
}