mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-06 19:35:44 +02:00
commit
6898012ebc
13 changed files with 947 additions and 339 deletions
|
|
@ -21,6 +21,8 @@ interface EditableFieldProps {
|
|||
light?: boolean;
|
||||
mentions?: boolean;
|
||||
mentionsAtValues?: Match[];
|
||||
showSaveButton?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export function EditableField({
|
||||
|
|
@ -36,6 +38,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 +99,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"
|
||||
|
|
@ -154,14 +158,19 @@ export function EditableField({
|
|||
</div>}
|
||||
</>) : (
|
||||
<>
|
||||
{markdown && <div className="max-h-[420px] overflow-y-auto text-gray-400 italic">
|
||||
{markdown && <div className="max-h-[420px] overflow-y-auto text-gray-400">
|
||||
<MarkdownContent content={placeholder} atValues={mentionsAtValues} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
18
apps/rowboat/app/lib/components/form-section.tsx
Normal file
18
apps/rowboat/app/lib/components/form-section.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
82
apps/rowboat/app/lib/components/structured-panel.tsx
Normal file
82
apps/rowboat/app/lib/components/structured-panel.tsx
Normal 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>;
|
||||
}
|
||||
|
|
@ -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,8 +87,8 @@ 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 runsPerPage = 10;
|
||||
const currentPage = Number(searchParams.get('page')) || 1;
|
||||
|
||||
const setMenuOpenId = useCallback((id: string | null) => {
|
||||
setMenuOpenIdState(id);
|
||||
|
|
@ -373,82 +381,22 @@ export default function SimulationApp() {
|
|||
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 +404,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
|
||||
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'
|
||||
}`}
|
||||
<StructuredPanel
|
||||
title="SIMULATION RUNS"
|
||||
tooltip="Run and view simulations"
|
||||
actions={[
|
||||
<ActionButton
|
||||
key="run-all"
|
||||
onClick={() => void runAllScenarios()}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import { DataSource } from "../../../lib/types/datasource_types";
|
|||
import { Button, Divider, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Input, Radio, RadioGroup, Select, SelectItem } from "@nextui-org/react";
|
||||
import { z } from "zod";
|
||||
import { DataSourceIcon } from "../../../lib/components/datasource-icon";
|
||||
import { ActionButton, Pane } from "./pane";
|
||||
import { ActionButton, StructuredPanel } from "../../../lib/components/structured-panel";
|
||||
import { EditableField } from "../../../lib/components/editable-field";
|
||||
import { Label } from "../../../lib/components/label";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
|
|
@ -57,7 +57,7 @@ export function AgentConfig({
|
|||
});
|
||||
}
|
||||
|
||||
return <Pane title={agent.name} actions={[
|
||||
return <StructuredPanel title={agent.name} actions={[
|
||||
<ActionButton
|
||||
key="close"
|
||||
onClick={handleClose}
|
||||
|
|
@ -269,5 +269,5 @@ export function AgentConfig({
|
|||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</Pane>;
|
||||
</StructuredPanel>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 { XMarkIcon } 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={<XMarkIcon className="w-4 h-4" />}
|
||||
>
|
||||
Close
|
||||
</ActionButton>
|
||||
|
|
@ -104,5 +103,5 @@ export function PromptConfig({
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Pane>;
|
||||
</StructuredPanel>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue