Merge pull request #34 from rowboatlabs/refactor

Add dark mode support, app styling, and editable field improvements
This commit is contained in:
Akhilesh Sudhakar 2025-02-24 11:48:29 +05:30 committed by GitHub
commit 98010d90a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 893 additions and 1074 deletions

View file

@ -75,6 +75,30 @@ html, body {
body {
@apply bg-background text-foreground;
}
/* Add these new utility classes */
.card-shadow {
@apply shadow-sm dark:shadow-none dark:border-border;
}
.hover-effect {
@apply hover:bg-accent/10 dark:hover:bg-accent/20 transition-colors;
}
.border-subtle {
@apply border-border dark:border-border/50;
}
}
/* Add this to disable color transitions */
* {
-webkit-transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out, opacity 0.2s ease-in-out !important;
transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out, opacity 0.2s ease-in-out !important;
}
/* Ensure smooth transitions */
* {
@apply transition-colors duration-200;
}
/* Add these styles alongside your other global styles */
@ -86,6 +110,40 @@ html, body {
z-index: 1000;
}
/* Base dark mode styles */
.dark .ql-mention-list-container {
background-color: #1f2937;
border-color: #374151;
}
.dark .ql-mention-list-container * {
background-color: #1f2937 !important;
color: #f9fafb !important;
}
/* Target individual items */
.dark .ql-mention-list-item {
color: #f9fafb !important;
background-color: #1f2937 !important;
}
/* Target hover and selected states for individual items */
.dark .ql-mention-list-container .ql-mention-list-item.selected,
.dark .ql-mention-list-container .ql-mention-list-item:hover {
background-color: #6b7280 !important;
}
/* Ensure the background color only applies to the item itself */
.dark .ql-mention-list-item > * {
background-color: inherit !important;
}
/* Additional catch-all for any other possible class combinations */
.dark [class*="mention"].selected,
.dark [class*="mention"]:hover {
background-color: #6b7280 !important;
}
.ql-mention-list {
padding: 0.5rem 0;
}
@ -108,4 +166,29 @@ html, body {
.ql-editor .mention .invalid {
color: red;
}
/* Add custom scrollbar styling */
.dark *::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.dark *::-webkit-scrollbar-track {
background: #1f2937; /* dark gray background */
}
.dark *::-webkit-scrollbar-thumb {
background: #4b5563; /* medium gray thumb */
border-radius: 4px;
}
.dark *::-webkit-scrollbar-thumb:hover {
background: #6b7280; /* lighter gray on hover */
}
/* For Firefox */
.dark * {
scrollbar-width: thin;
scrollbar-color: #4b5563 #1f2937;
}

View file

@ -1,4 +1,5 @@
import "./globals.css";
import { ThemeProvider } from "./providers/theme-provider";
import { UserProvider } from '@auth0/nextjs-auth0/client';
import { Inter } from "next/font/google";
import { Providers } from "./providers";
@ -20,11 +21,13 @@ export default function RootLayout({
}>) {
return <html lang="en" className="h-dvh">
<UserProvider>
<body className={`${inter.className} h-full text-base [scrollbar-width:thin] bg-gray-100`}>
<Providers className='h-full flex flex-col'>
{children}
</Providers>
</body>
<ThemeProvider>
<body className={`${inter.className} h-full text-base [scrollbar-width:thin] bg-background`}>
<Providers className='h-full flex flex-col'>
{children}
</Providers>
</body>
</ThemeProvider>
</UserProvider>
</html>;
}

View file

@ -0,0 +1,37 @@
import { Select, SelectItem } from "@nextui-org/react";
import { ReactNode } from "react";
export interface DropdownOption {
key: string;
label: string;
}
interface DropdownProps {
options: DropdownOption[];
value: string;
onChange: (value: string) => void;
className?: string;
}
export function Dropdown({
options,
value,
onChange,
className = "w-60"
}: DropdownProps) {
return (
<Select
variant="bordered"
selectedKeys={[value]}
size="sm"
className={className}
onSelectionChange={(keys) => onChange(keys.currentKey as string)}
>
{options.map((option) => (
<SelectItem key={option.key} value={option.key}>
{option.label}
</SelectItem>
))}
</Select>
);
}

View file

@ -6,6 +6,7 @@ import clsx from "clsx";
import { Label } from "./label";
import dynamic from "next/dynamic";
import { Match } from "./mentions_editor";
import { SparklesIcon } from "lucide-react";
const MentionsEditor = dynamic(() => import('./mentions_editor'), { ssr: false });
interface EditableFieldProps {
@ -22,7 +23,13 @@ interface EditableFieldProps {
mentions?: boolean;
mentionsAtValues?: Match[];
showSaveButton?: boolean;
showDiscardButton?: boolean;
error?: string | null;
inline?: boolean;
showGenerateButton?: {
show: boolean;
setShow: (show: boolean) => void;
};
}
export function EditableField({
@ -33,13 +40,16 @@ export function EditableField({
markdown = false,
multiline = false,
locked = false,
className = "flex flex-col gap-1",
className = "flex flex-col gap-1 w-full",
validate,
light = false,
mentions = false,
mentionsAtValues = [],
showSaveButton = multiline,
showSaveButton = false,
showDiscardButton = false,
error,
inline = false,
showGenerateButton,
}: EditableFieldProps) {
const [isEditing, setIsEditing] = useState(false);
const [localValue, setLocalValue] = useState(value);
@ -70,7 +80,11 @@ export function EditableField({
variant: "bordered" as const,
labelPlacement: "outside" as const,
placeholder: markdown ? '' : placeholder,
radius: "sm" as const,
classNames: {
input: "rounded-md",
inputWrapper: "rounded-md border-medium"
},
radius: "md" as const,
isInvalid: !isValid,
errorMessage: validationResult?.errorMessage,
onKeyDown: (e: React.KeyboardEvent) => {
@ -97,80 +111,147 @@ export function EditableField({
},
};
return (
<div ref={ref} className={clsx("flex flex-col gap-1", className)}>
{(label || isEditing && showSaveButton) && <div className="flex items-center gap-2 justify-between">
{label && <Label label={label} />}
{isEditing && showSaveButton && <div className="flex items-center gap-2">
<Button
size="sm"
variant="light"
onClick={() => {
setLocalValue(value);
setIsEditing(false);
}}
>
Cancel
</Button>
<Button
size="sm"
color="primary"
onClick={() => {
if (isValid && localValue !== value) {
onChange(localValue);
}
setIsEditing(false);
}}
>
Save
</Button>
</div>}
</div>}
{isEditing ? <>
{mentions && <MentionsEditor
atValues={mentionsAtValues}
value={value}
placeholder={placeholder}
onValueChange={setLocalValue}
/>}
if (isEditing) {
const hasChanges = localValue !== value;
return (
<div ref={ref} className={clsx("flex flex-col gap-1 w-full", className)}>
{label && (
<div className="flex justify-between items-center">
<Label label={label} />
<div className="flex gap-2 items-center">
{showGenerateButton && (
<Button
variant="light"
size="sm"
startContent={<SparklesIcon size={16} />}
onPress={() => showGenerateButton.setShow(true)}
>
Generate
</Button>
)}
{hasChanges && (
<>
{showDiscardButton && (
<Button
variant="light"
size="sm"
onPress={() => {
setLocalValue(value);
setIsEditing(false);
}}
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
>
Discard
</Button>
)}
{showSaveButton && (
<Button
color="primary"
size="sm"
onPress={() => {
if (isValid && localValue !== value) {
onChange(localValue);
}
setIsEditing(false);
}}
>
Save
</Button>
)}
</>
)}
</div>
</div>
)}
{mentions && (
<div className="w-full rounded-md border-2 border-default-300">
<MentionsEditor
atValues={mentionsAtValues}
value={value}
placeholder={placeholder}
onValueChange={setLocalValue}
/>
</div>
)}
{multiline && !mentions && <Textarea
{...commonProps}
minRows={3}
maxRows={20}
className="w-full"
classNames={{
...commonProps.classNames,
input: "rounded-md py-2",
inputWrapper: "rounded-md border-medium py-1"
}}
/>}
{!multiline && <Input {...commonProps} />}
</> : (
<div
onClick={() => !locked && setIsEditing(true)}
className={clsx("text-sm px-2 py-1 rounded-md", {
"bg-gray-50": (markdown && !locked) || light,
"hover:bg-blue-50 cursor-pointer": light && !locked,
"hover:bg-gray-100 cursor-pointer": !light && !locked,
"cursor-default": locked,
})}
>
{value ? (<>
{!multiline && <Input
{...commonProps}
className="w-full"
classNames={{
...commonProps.classNames,
input: "rounded-md py-2",
inputWrapper: "rounded-md border-medium py-1"
}}
/>}
</div>
);
}
return (
<div ref={ref} className={clsx("cursor-text", className)}>
{label && (
<div className="flex justify-between items-center">
<Label label={label} />
{showGenerateButton && (
<Button
variant="light"
size="sm"
startContent={<SparklesIcon size={16} />}
onPress={() => showGenerateButton.setShow(true)}
>
Generate
</Button>
)}
</div>
)}
<div
className={clsx(
{
"border border-gray-300 dark:border-gray-600 rounded px-3 py-3": !inline,
"bg-transparent focus:outline-none focus:ring-0 border-0 rounded-none text-gray-900 dark:text-gray-100": inline,
}
)}
style={inline ? {
border: 'none',
borderRadius: '0',
padding: '0'
} : undefined}
onClick={() => !locked && setIsEditing(true)}
>
{value ? (
<>
{markdown && <div className="max-h-[420px] overflow-y-auto">
<MarkdownContent content={value} atValues={mentionsAtValues} />
</div>}
{!markdown && <div className={`${multiline ? 'whitespace-pre-wrap max-h-[420px] overflow-y-auto' : 'flex items-center'}`}>
<MarkdownContent content={value} atValues={mentionsAtValues} />
</div>}
</>) : (
<>
{markdown && <div className="max-h-[420px] overflow-y-auto text-gray-400">
<MarkdownContent content={placeholder} atValues={mentionsAtValues} />
</div>}
{!markdown && <span className="text-gray-400">{placeholder}</span>}
</>
)}
</div>
)}
{error && (
<div className="text-xs text-red-500 mt-1">
{error}
</div>
)}
</>
) : (
<>
{markdown && <div className="max-h-[420px] overflow-y-auto text-gray-400">
<MarkdownContent content={placeholder} atValues={mentionsAtValues} />
</div>}
{!markdown && <span className="text-gray-400">{placeholder}</span>}
</>
)}
{error && (
<div className="text-xs text-red-500 mt-1">
{error}
</div>
)}
</div>
</div>
);
}

View file

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

View file

@ -0,0 +1,36 @@
/* Target both edit mode and view mode mentions */
.mention,
.ql-editor p span[class*="bg-[#e"], /* Matches both #e8f2fe and #e0f2fe */
span[class*="bg-[#e"] { /* For view mode */
background-color: #e8f2fe !important;
color: #1e40af !important;
}
/* Dark mode overrides */
.dark .mention,
.dark .ql-editor p span[class*="bg-[#e"],
.dark span[class*="bg-[#e"] {
background-color: rgb(31 41 55) !important; /* bg-gray-800 */
color: rgb(243 244 246) !important; /* text-gray-100 */
}
/* Handle Next.js dark mode class if needed */
:global(.dark) .mention,
:global(.dark) .ql-editor p span[class*="bg-[#e"],
:global(.dark) span[class*="bg-[#e"] {
background-color: rgb(31 41 55) !important;
color: rgb(243 244 246) !important;
}
/* Override the inline styles */
.ql-editor p span[class*="bg-[#e0f2fe]"],
.ql-editor p span[class*="bg-[#e8f2fe]"] {
background-color: rgb(31 41 55) !important; /* bg-gray-800 */
color: rgb(243 244 246) !important; /* text-gray-100 */
}
/* Target our custom class */
.dark .mention-tag {
background-color: rgb(31 41 55) !important; /* bg-gray-800 */
color: rgb(243 244 246) !important; /* text-gray-100 */
}

View file

@ -3,6 +3,7 @@ import { useEffect, useRef } from 'react';
import Quill, { Delta, Op } from 'quill';
import { Mention, MentionBlot, MentionBlotData } from "quill-mention";
import "quill/dist/quill.snow.css";
import "./mentions-editor.css";
import { CopyIcon } from 'lucide-react';
import { CopyButton } from './copy-button';

View file

@ -0,0 +1,31 @@
import React from 'react';
import clsx from 'clsx';
interface MenuItemProps {
icon: React.ReactNode;
children: React.ReactNode;
selected: boolean;
onClick: () => void;
}
const MenuItem: React.FC<MenuItemProps> = ({ icon, children, selected, onClick }) => {
return (
<button
className={clsx(
"w-full flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors",
"hover:bg-gray-100 dark:hover:bg-gray-800",
{
"bg-gray-100 dark:bg-gray-800": selected,
"text-gray-600 dark:text-gray-400": !selected,
"text-gray-900 dark:text-gray-100": selected,
}
)}
onClick={onClick}
>
{icon}
{children}
</button>
);
};
export default MenuItem;

View file

@ -0,0 +1,50 @@
import clsx from "clsx";
import { ActionButton } from "./structured-panel";
export 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 dark:border-gray-600">
<div className="text-xs font-semibold text-gray-400 dark:text-gray-300 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>
);
}
export function ListItem({
name,
isSelected,
onClick,
disabled,
rightElement,
selectedRef
}: {
name: string;
isSelected: boolean;
onClick: () => void;
disabled?: boolean;
rightElement?: React.ReactNode;
selectedRef?: React.RefObject<HTMLButtonElement>;
}) {
return (
<button
ref={selectedRef as any}
onClick={onClick}
className={clsx("flex items-center justify-between rounded-md px-2 py-1", {
"bg-gray-100 dark:bg-gray-700": isSelected,
"hover:bg-gray-50 dark:hover:bg-gray-800": !isSelected,
})}
>
<div className={clsx("truncate text-sm dark:text-gray-200", {
"text-gray-400 dark:text-gray-500": disabled,
})}>{name}</div>
{rightElement}
</button>
);
}

View file

@ -18,9 +18,9 @@ export function ActionButton({
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,
className={clsx("rounded-md text-xs flex items-center gap-1 disabled:text-gray-300 dark:disabled:text-gray-600 hover:text-gray-600 dark:hover:text-gray-300", {
"text-blue-600 dark:text-blue-400": primary,
"text-gray-400 dark:text-gray-500": !primary,
})}
{...onClickProp}
>
@ -43,14 +43,14 @@ export function StructuredPanel({
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,
"bg-gray-100 dark:bg-gray-800": !fancy,
"bg-blue-100 dark:bg-blue-900": 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,
"text-gray-400 dark:text-gray-500": !fancy,
"text-blue-500 dark:text-blue-400": fancy,
})}>
{title}
</div>
@ -61,21 +61,21 @@ export function StructuredPanel({
className="cursor-help"
>
<InfoIcon size={12} className={clsx({
"text-gray-400": !fancy,
"text-blue-500": fancy,
"text-gray-400 dark:text-gray-500": !fancy,
"text-blue-500 dark:text-blue-400": 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 className={clsx("rounded-md hover:text-gray-800 dark:hover:text-gray-200 px-2 text-sm flex items-center gap-2", {
"text-blue-600 dark:text-blue-400": fancy,
"text-gray-400 dark:text-gray-500": !fancy,
})}>
{actions}
</div>}
</div>
<div className="grow bg-white rounded-md overflow-auto flex flex-col justify-start p-2">
<div className="grow bg-white dark:bg-gray-900 rounded-md overflow-auto flex flex-col justify-start p-2">
{children}
</div>
</div>;

View file

@ -0,0 +1,21 @@
'use client'
import { Moon, Sun } from "lucide-react"
import { useTheme } from "@/app/providers/theme-provider"
import { Button } from "@nextui-org/react"
export function ThemeToggle() {
const { theme, toggleTheme } = useTheme()
return (
<Button
variant="light"
isIconOnly
onClick={toggleTheme}
aria-label="Toggle theme"
className="text-foreground"
>
{theme === 'light' ? <Moon size={20} /> : <Sun size={20} />}
</Button>
)
}

View file

@ -11,8 +11,8 @@ export default async function Layout({
return <div className="flex h-full">
<Nav projectId={params.projectId} useDataSources={useDataSources} />
<div className="grow p-2 overflow-auto bg-white rounded-tl-lg">
<div className="grow p-2 overflow-auto bg-background dark:bg-background rounded-tl-lg">
{children}
</div>
</div >;
</div>;
}

View file

@ -2,29 +2,43 @@
import { usePathname } from "next/navigation";
import { Tooltip } from "@nextui-org/react";
import Link from "next/link";
import clsx from "clsx";
import { DatabaseIcon, SettingsIcon, WorkflowIcon, PlayIcon } from "lucide-react";
import MenuItem from "../../lib/components/menu-item";
function NavLink({ href, label, icon, collapsed, selected = false }: { href: string, label: string, icon: React.ReactNode, collapsed: boolean, selected?: boolean }) {
return <Link
href={href}
className={clsx("flex px-2 py-2 gap-2 items-center rounded-lg text-sm hover:text-black", {
"text-black": selected,
"justify-center": collapsed,
})}
>
{collapsed && <Tooltip content={label} showArrow placement="right">
<div className="shrink-0">
{icon}
</div>
</Tooltip>}
{!collapsed && <div className="shrink-0">
{icon}
</div>}
{!collapsed && <div className="truncate">
{label}
</div>}
</Link>;
function NavLink({ href, label, icon, collapsed, selected = false }: {
href: string,
label: string,
icon: React.ReactNode,
collapsed: boolean,
selected?: boolean
}) {
if (collapsed) {
return (
<Tooltip content={label} showArrow placement="right">
<Link href={href} className="block">
<MenuItem
icon={icon}
selected={selected}
onClick={() => {}}
>
<span className="sr-only">{label}</span>
</MenuItem>
</Link>
</Tooltip>
);
}
return (
<Link href={href}>
<MenuItem
icon={icon}
selected={selected}
onClick={() => {}}
>
{label}
</MenuItem>
</Link>
);
}
export default function Menu({
@ -38,52 +52,38 @@ export default function Menu({
}) {
const pathname = usePathname();
return <div className="flex flex-col text-gray-500">
{/* <NavLink
href={`/projects/${projectId}/playground`}
label="Playground"
collapsed={collapsed}
icon=<svg className="w-[24px] h-[24px]" 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="M9 17h6l3 3v-3h2V9h-2M4 4h11v8H9l-3 3v-3H4V4Z" />
</svg>
selected={pathname.startsWith(`/projects/${projectId}/playground`)}
/> */}
<NavLink
href={`/projects/${projectId}/workflow`}
label="Build"
collapsed={collapsed}
icon={<WorkflowIcon size={16} />}
selected={pathname.startsWith(`/projects/${projectId}/workflow`)}
/>
<NavLink
href={`/projects/${projectId}/simulation`}
label="Test"
collapsed={collapsed}
icon={<PlayIcon size={16} />}
selected={pathname.startsWith(`/projects/${projectId}/simulation`)}
/>
{useDataSources && <NavLink
href={`/projects/${projectId}/sources`}
label="Connect"
collapsed={collapsed}
icon={<DatabaseIcon size={16} />}
selected={pathname.startsWith(`/projects/${projectId}/sources`)}
/>}
<NavLink
href={`/projects/${projectId}/config`}
label="Integrate"
collapsed={collapsed}
icon={<SettingsIcon size={16} />}
selected={pathname.startsWith(`/projects/${projectId}/config`)}
/>
{/*<NavLink
href={`/projects/${projectId}/integrate`}
label="Integrate"
collapsed={collapsed}
icon=<svg className="w-[24px] h-[24px]" 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="m8 8-4 4 4 4m8 0 4-4-4-4m-2-3-4 14" />
</svg>
selected={pathname.startsWith(`/projects/${projectId}/integrate`)}
/>*/}
</div>;
return (
<div className="flex flex-col gap-1">
<NavLink
href={`/projects/${projectId}/workflow`}
label="Build"
collapsed={collapsed}
icon={<WorkflowIcon size={16} />}
selected={pathname.startsWith(`/projects/${projectId}/workflow`)}
/>
<NavLink
href={`/projects/${projectId}/simulation`}
label="Test"
collapsed={collapsed}
icon={<PlayIcon size={16} />}
selected={pathname.startsWith(`/projects/${projectId}/simulation`)}
/>
{useDataSources && (
<NavLink
href={`/projects/${projectId}/sources`}
label="Connect"
collapsed={collapsed}
icon={<DatabaseIcon size={16} />}
selected={pathname.startsWith(`/projects/${projectId}/sources`)}
/>
)}
<NavLink
href={`/projects/${projectId}/config`}
label="Integrate"
collapsed={collapsed}
icon={<SettingsIcon size={16} />}
selected={pathname.startsWith(`/projects/${projectId}/config`)}
/>
</div>
);
}

View file

@ -40,11 +40,8 @@ export function Nav({
</button>
</Tooltip>
{!collapsed && <div className="flex flex-col gap-1">
<Tooltip content="Change project" showArrow placement="bottom-end">
<Link className="relative group flex flex-col px-2 py-2 border border-gray-200 rounded-md hover:border-gray-500" href="/projects">
<div className="absolute top-[-7px] left-1 px-1 bg-gray-100 text-xs text-gray-400 group-hover:text-gray-600">
Project
</div>
<Tooltip content="Change project" showArrow placement="bottom-end" delay={0} closeDelay={0}>
<Link className="relative group flex flex-col px-2 py-2 border border-gray-200 rounded-md hover:border-gray-500 transition-colors duration-100" href="/projects">
<div className="flex flex-row items-center gap-2">
<FolderOpenIcon size={16} />
<div className="truncate text-sm">

View file

@ -12,6 +12,7 @@ import { ActionButton, Pane } from "../workflow/pane";
import { apiV1 } from "rowboat-shared";
import { EllipsisVerticalIcon, MessageSquarePlusIcon, PlayIcon } from "lucide-react";
import { getScenario } from "../../../actions/simulation_actions";
import clsx from "clsx";
function SimulateLabel() {
return <span>Simulate<sup className="pl-1">beta</sup></span>;

View file

@ -60,7 +60,7 @@ export function ComposeBox({
isIconOnly
disabled={disabled}
onClick={handleInput}
className="bg-gray-100"
className="bg-default-100"
>
<CornerDownLeftIcon size={16} />
</Button>}

View file

@ -15,10 +15,10 @@ import { MessageSquareIcon, EllipsisIcon, CircleCheckIcon, ChevronsDownIcon, Che
function UserMessage({ content }: { content: string }) {
return <div className="self-end ml-[30%] flex flex-col">
<div className="text-right text-gray-500 text-xs mr-3">
<div className="text-right text-gray-500 dark:text-gray-400 text-xs mr-3">
User
</div>
<div className="bg-gray-100 px-3 py-1 rounded-lg rounded-br-none text-sm">
<div className="bg-gray-100 dark:bg-gray-800 px-3 py-1 rounded-lg rounded-br-none text-sm text-gray-900 dark:text-gray-100">
<MarkdownContent content={content} />
</div>
</div>;
@ -27,23 +27,22 @@ function UserMessage({ content }: { content: string }) {
function InternalAssistantMessage({ content, sender, latency }: { content: string, sender: string | null | undefined, latency: number }) {
const [expanded, setExpanded] = useState(false);
// show a message icon with a + symbol to expand and show the content
return <div className="self-start mr-[30%]">
{!expanded && <button className="flex items-center text-gray-400 hover:text-gray-600 gap-1 group" onClick={() => setExpanded(true)}>
{!expanded && <button className="flex items-center text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 gap-1 group" onClick={() => setExpanded(true)}>
<MessageSquareIcon size={16} />
<EllipsisIcon size={16} />
<span className="hidden group-hover:block text-xs">Show debug message</span>
</button>}
{expanded && <div className="flex flex-col">
<div className="flex gap-2 justify-between items-center">
<div className="text-gray-500 text-xs pl-3">
<div className="text-gray-500 dark:text-gray-400 text-xs pl-3">
{sender ?? 'Assistant'}
</div>
<button className="flex items-center gap-1 text-gray-400 hover:text-gray-600" onClick={() => setExpanded(false)}>
<button className="flex items-center gap-1 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300" onClick={() => setExpanded(false)}>
<XIcon size={16} />
</button>
</div>
<div className="border border-gray-300 border-dashed px-3 py-1 rounded-lg rounded-bl-none">
<div className="border border-gray-300 dark:border-gray-700 border-dashed px-3 py-1 rounded-lg rounded-bl-none text-gray-900 dark:text-gray-100">
<pre className="text-sm whitespace-pre-wrap">{content}</pre>
</div>
</div>}
@ -53,22 +52,22 @@ function InternalAssistantMessage({ content, sender, latency }: { content: strin
function AssistantMessage({ content, sender, latency }: { content: string, sender: string | null | undefined, latency: number }) {
return <div className="self-start mr-[30%] flex flex-col">
<div className="flex gap-2 justify-between items-center">
<div className="text-gray-500 text-xs pl-3">
<div className="text-gray-500 dark:text-gray-400 text-xs pl-3">
{sender ?? 'Assistant'}
</div>
<div className="text-gray-400 text-xs pr-3">
<div className="text-gray-400 dark:text-gray-500 text-xs pr-3">
{Math.round(latency / 1000)}s
</div>
</div>
<div className="bg-gray-100 px-3 py-1 rounded-lg rounded-bl-none text-sm">
<div className="bg-gray-100 dark:bg-gray-800 px-3 py-1 rounded-lg rounded-bl-none text-sm text-gray-900 dark:text-gray-100">
<MarkdownContent content={content} />
</div>
</div>;
}
function AssistantMessageLoading() {
return <div className="self-start mr-[30%] flex flex-col text-gray-500 items-start">
<div className="text-gray-500 text-xs ml-3">
return <div className="self-start mr-[30%] flex flex-col text-gray-500 dark:text-gray-400 items-start">
<div className="text-gray-500 dark:text-gray-400 text-xs ml-3">
Assistant
</div>
<Spinner size="sm" className="mt-2 ml-3" />
@ -77,10 +76,10 @@ function AssistantMessageLoading() {
function UserMessageLoading() {
return <div className="self-end ml-[30%] flex flex-col">
<div className="text-right text-gray-500 text-sm mr-3">
<div className="text-right text-gray-500 dark:text-gray-400 text-sm mr-3">
User
</div>
<div className="bg-gray-100 p-3 rounded-lg rounded-br-none animate-pulse w-20 text-gray-800">
<div className="bg-gray-100 dark:bg-gray-800 p-3 rounded-lg rounded-br-none animate-pulse w-20 text-gray-800 dark:text-gray-200">
<Spinner size="sm" />
</div>
</div>;
@ -219,7 +218,7 @@ function ToolCallHeader({
{!result && <Spinner size="sm" />}
{result && <CircleCheckIcon size={16} />}
<div className='font-semibold text-sm'>
Function Call: <span className='bg-gray-100 px-2 py-1 rounded-lg font-medium'>{toolCall.function.name}</span>
Function Call: <code className='bg-gray-100 dark:bg-neutral-800 px-2 py-0.5 rounded font-mono'>{toolCall.function.name}</code>
</div>
</div>
</div>;
@ -567,9 +566,14 @@ function MockToolCall({
}, [autoSubmit, response, handleSubmit, result]);
return <div className="flex flex-col gap-1">
{sender && <div className='text-gray-500 text-xs ml-3'>{sender}</div>}
<div className='border border-gray-300 p-2 pt-2 rounded-lg rounded-bl-none flex flex-col gap-2 mr-[30%]'>
<ToolCallHeader toolCall={toolCall} result={result} />
{sender && <div className='text-gray-500 dark:text-gray-400 text-xs ml-3'>{sender}</div>}
<div className='border border-gray-300 dark:border-gray-700 p-2 pt-2 rounded-lg rounded-bl-none flex flex-col gap-2 mr-[30%] bg-white dark:bg-gray-900'>
<div className="flex items-center gap-2">
<CircleCheckIcon size={16} className="text-gray-500 dark:text-gray-400" />
<span className="text-sm text-gray-700 dark:text-gray-300">
Function Call: <code className='bg-gray-100 dark:bg-neutral-800 px-2 py-0.5 rounded font-mono'>{toolCall.function.name}</code>
</span>
</div>
<div className='flex flex-col gap-2'>
<ExpandableContent label='Params' content={toolCall.function.arguments} expanded={false} />
@ -633,12 +637,12 @@ function ExpandableContent({
}
return <div className='flex flex-col gap-2'>
<div className='flex gap-1 items-start cursor-pointer text-gray-500' onClick={toggleExpanded}>
<div className='flex gap-1 items-start cursor-pointer text-gray-500 dark:text-gray-400' onClick={toggleExpanded}>
{!isExpanded && <ChevronRightIcon size={16} />}
{isExpanded && <ChevronDownIcon size={16} />}
<div className='text-left break-all text-xs'>{label}</div>
</div>
{isExpanded && <pre className='text-sm font-mono bg-gray-100 p-2 rounded break-all whitespace-pre-wrap overflow-x-auto'>
{isExpanded && <pre className='text-sm font-mono bg-gray-100 dark:bg-gray-800 p-2 rounded break-all whitespace-pre-wrap overflow-x-auto text-gray-900 dark:text-gray-100'>
{formattedContent}
</pre>}
</div>;
@ -654,10 +658,10 @@ function SystemMessage({
locked: boolean
}) {
return (
<div className="border border-gray-300 p-2 rounded-lg flex flex-col gap-2">
<div className="border border-gray-300 dark:border-gray-700 p-2 rounded-lg flex flex-col gap-2 bg-white dark:bg-gray-900">
<div className="text-sm text-gray-500 dark:text-gray-400 font-medium">CONTEXT</div>
<EditableField
light
label="Context"
value={content}
onChange={onChange}
multiline

View file

@ -6,6 +6,7 @@ import { WithStringId } from '../../../../lib/types/types';
import { Scenario, SimulationRun, SimulationResult, SimulationAggregateResult } from "../../../../lib/types/testing_types";
import { z } from 'zod';
import { Workflow } from "../../../../lib/types/workflow_types";
import { clsx } from 'clsx';
type ScenarioType = WithStringId<z.infer<typeof Scenario>>;
type SimulationRunType = WithStringId<z.infer<typeof SimulationRun>>;
@ -31,23 +32,23 @@ export const SimulationResultCard = ({ run, results, scenarios, workflow, onCanc
const passedScenarios = run.aggregateResults?.pass ?? 0;
const failedScenarios = run.aggregateResults?.fail ?? 0;
const statusLabelClass = "w-[110px] px-3 py-1 rounded text-xs text-center uppercase font-semibold inline-block";
const getStatusClass = (status: string) => {
const baseClass = "w-[110px] px-3 py-1 rounded text-xs text-center uppercase font-semibold inline-block";
switch (status) {
case 'completed':
case 'pass':
return `${statusLabelClass} bg-green-50 text-green-800`;
return `${baseClass} bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-400`;
case 'failed':
case 'fail':
return `${statusLabelClass} bg-red-50 text-red-800`;
return `${baseClass} bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400`;
case 'error':
return `${statusLabelClass} bg-orange-50 text-orange-800`;
return `${baseClass} bg-orange-50 dark:bg-orange-900/20 text-orange-800 dark:text-orange-400`;
case 'cancelled':
return `${statusLabelClass} bg-gray-50 text-gray-800`;
return `${baseClass} bg-gray-50 dark:bg-neutral-800 text-gray-800 dark:text-neutral-400`;
case 'running':
case 'pending':
default:
return `${statusLabelClass} bg-yellow-50 text-yellow-800`;
return `${baseClass} bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-400`;
}
};
@ -105,18 +106,24 @@ export const SimulationResultCard = ({ run, results, scenarios, workflow, onCanc
}, [menuOpenId, setMenuOpenId]);
return (
<div className="border rounded-lg mb-4 shadow-sm">
<div className="border dark:border-neutral-800 rounded-lg mb-4 shadow-sm">
<div
className="p-4 flex items-center justify-between cursor-pointer hover:bg-gray-50"
className={clsx(
"p-4 flex items-center justify-between cursor-pointer",
"transition-colors duration-200",
"hover:bg-neutral-100 dark:hover:bg-neutral-800",
"border-b border-transparent",
isExpanded && "border-b-neutral-200 dark:border-b-neutral-800"
)}
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center space-x-2">
{isExpanded ? (
<ChevronDownIcon className="h-5 w-5 text-gray-400" />
<ChevronDownIcon className="h-5 w-5 text-gray-400 dark:text-neutral-500" />
) : (
<ChevronRightIcon className="h-5 w-5 text-gray-400" />
<ChevronRightIcon className="h-5 w-5 text-gray-400 dark:text-neutral-500" />
)}
<div className="text-sm truncate">
<div className="text-sm truncate dark:text-neutral-200">
{formatMainTitle(run.startedAt)}
</div>
</div>
@ -130,13 +137,13 @@ export const SimulationResultCard = ({ run, results, scenarios, workflow, onCanc
e.stopPropagation();
setMenuOpenId(menuOpenId === run._id ? null : run._id);
}}
className="p-1 rounded-full hover:bg-gray-100"
className="p-1 rounded-full hover:bg-gray-100 dark:hover:bg-neutral-700"
>
<EllipsisVerticalIcon className="h-5 w-5 text-gray-600" />
<EllipsisVerticalIcon className="h-5 w-5 text-gray-600 dark:text-neutral-400" />
</button>
{menuOpenId === run._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="absolute right-0 mt-1 w-48 rounded-md shadow-lg bg-white dark:bg-neutral-900 ring-1 ring-black ring-opacity-5 dark:ring-neutral-700 z-10">
<div className="py-1">
{(run.status === 'running' || run.status === 'pending') && onCancelRun && (
<button
@ -145,7 +152,7 @@ export const SimulationResultCard = ({ run, results, scenarios, workflow, onCanc
onCancelRun(run._id);
setMenuOpenId(null);
}}
className="flex items-center px-4 py-2 text-sm text-red-600 hover:bg-gray-100 w-full"
className="flex items-center px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-gray-100 dark:hover:bg-neutral-800 w-full"
>
<NoSymbolIcon className="h-4 w-4 mr-2" />
Cancel Run
@ -153,7 +160,7 @@ export const SimulationResultCard = ({ run, results, scenarios, workflow, onCanc
)}
<button
disabled
className="flex items-center px-4 py-2 text-sm text-gray-400 w-full cursor-not-allowed whitespace-nowrap"
className="flex items-center px-4 py-2 text-sm text-gray-400 dark:text-neutral-500 w-full cursor-not-allowed whitespace-nowrap"
>
<ArrowDownTrayIcon className="h-4 w-4 mr-2" />
Download transcripts
@ -164,7 +171,7 @@ export const SimulationResultCard = ({ run, results, scenarios, workflow, onCanc
setShowDeleteConfirm(true);
setMenuOpenId(null);
}}
className="flex items-center px-4 py-2 text-sm text-red-600 hover:bg-gray-100 w-full"
className="flex items-center px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-gray-100 dark:hover:bg-neutral-800 w-full"
>
<TrashIcon className="h-4 w-4 mr-2" />
Delete run
@ -177,7 +184,7 @@ export const SimulationResultCard = ({ run, results, scenarios, workflow, onCanc
</div>
{isExpanded && (
<div className="p-4 border-t">
<div className="p-4 border-t dark:border-neutral-800">
{run.status === 'error' ? (
<div className="text-orange-800 bg-orange-50 p-4 rounded-lg">
Your simulation could not be completed. Please run a new simulation again.
@ -187,36 +194,36 @@ export const SimulationResultCard = ({ run, results, scenarios, workflow, onCanc
{/* Workflow and timing information in a grid */}
<div className="grid grid-cols-3 gap-4 mb-6">
{workflow && (
<div className="bg-gray-50 p-4 rounded-lg">
<div className="text-sm font-medium text-gray-600 mb-1">Workflow Version</div>
<div className="font-medium">{workflow.name}</div>
<div className="bg-gray-50 dark:bg-neutral-800 p-4 rounded-lg">
<div className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-1">Workflow Version</div>
<div className="font-medium dark:text-neutral-200">{workflow.name}</div>
</div>
)}
<div className="bg-gray-50 p-4 rounded-lg">
<div className="text-sm font-medium text-gray-600 mb-1">Completed</div>
<div className="text-sm">
<div className="bg-gray-50 dark:bg-neutral-800 p-4 rounded-lg">
<div className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-1">Completed</div>
<div className="text-sm dark:text-neutral-300">
{run.completedAt ? formatDateTime(run.completedAt) : 'Not completed'}
</div>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<div className="text-sm font-medium text-gray-600 mb-1">Duration</div>
<div className="text-sm">{getDuration()}</div>
<div className="bg-gray-50 dark:bg-neutral-800 p-4 rounded-lg">
<div className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-1">Duration</div>
<div className="text-sm dark:text-neutral-300">{getDuration()}</div>
</div>
</div>
{/* Results statistics */}
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="p-4 rounded-lg bg-gray-50">
<div className="text-sm text-gray-600">Total Scenarios</div>
<div className="text-2xl font-semibold">{totalScenarios}</div>
<div className="p-4 rounded-lg bg-gray-50 dark:bg-neutral-800">
<div className="text-sm text-gray-600 dark:text-neutral-400">Total Scenarios</div>
<div className="text-2xl font-semibold dark:text-neutral-200">{totalScenarios}</div>
</div>
<div className="p-4 rounded-lg bg-green-50">
<div className="text-sm text-green-600">Passed</div>
<div className="text-2xl font-semibold text-green-700">{passedScenarios}</div>
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20">
<div className="text-sm text-green-600 dark:text-green-400">Passed</div>
<div className="text-2xl font-semibold text-green-700 dark:text-green-400">{passedScenarios}</div>
</div>
<div className="p-4 rounded-lg bg-red-50">
<div className="text-sm text-red-600">Failed</div>
<div className="text-2xl font-semibold text-red-700">{failedScenarios}</div>
<div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20">
<div className="text-sm text-red-600 dark:text-red-400">Failed</div>
<div className="text-2xl font-semibold text-red-700 dark:text-red-400">{failedScenarios}</div>
</div>
</div>
@ -229,23 +236,30 @@ export const SimulationResultCard = ({ run, results, scenarios, workflow, onCanc
return scenario && (
<div
key={scenarioId}
className={`border rounded-lg overflow-hidden ${
result?.result === 'pass' ? 'bg-green-50 border-green-200' :
result?.result === 'fail' ? 'bg-red-50 border-red-200' :
'bg-gray-50 border-gray-200'
}`}
className={clsx(
"border dark:border-neutral-800 rounded-lg overflow-hidden",
"transition-colors duration-200",
result?.result === 'pass'
? 'bg-green-50/50 dark:bg-green-900/10 border-green-200 dark:border-green-900/50'
: result?.result === 'fail'
? 'bg-red-50/50 dark:bg-red-900/10 border-red-200 dark:border-red-900/50'
: 'bg-gray-50/50 dark:bg-neutral-900/50 border-gray-200 dark:border-neutral-800'
)}
>
<div
className="p-3 flex items-center justify-between cursor-pointer hover:bg-opacity-80"
className={clsx(
"p-3 flex items-center justify-between cursor-pointer",
"hover:bg-white/50 dark:hover:bg-neutral-800/50"
)}
onClick={(e) => toggleScenario(scenarioId, e)}
>
<div className="flex items-center space-x-2">
{isScenarioExpanded ? (
<ChevronDownIcon className="h-4 w-4 text-gray-600" />
<ChevronDownIcon className="h-4 w-4 text-gray-600 dark:text-neutral-400" />
) : (
<ChevronRightIcon className="h-4 w-4 text-gray-600" />
<ChevronRightIcon className="h-4 w-4 text-gray-600 dark:text-neutral-400" />
)}
<span className="font-medium text-gray-900">{scenario.name}</span>
<span className="font-medium text-gray-900 dark:text-neutral-200">{scenario.name}</span>
</div>
{result && (
<span className={getStatusClass(result.result)}>
@ -255,29 +269,29 @@ export const SimulationResultCard = ({ run, results, scenarios, workflow, onCanc
</div>
{isScenarioExpanded && (
<div className="p-3 border-t border-opacity-50 space-y-4">
<div className="p-3 border-t border-opacity-50 dark:border-neutral-800 space-y-4">
<div>
<div className="text-sm font-medium mb-1">Description</div>
<div className="text-sm text-gray-700">
<div className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-1">Description</div>
<div className="text-sm text-gray-700 dark:text-neutral-300">
{scenario.description}
</div>
</div>
<div>
<div className="text-sm font-medium mb-1">Criteria</div>
<div className="text-sm text-gray-700">
<div className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-1">Criteria</div>
<div className="text-sm text-gray-700 dark:text-neutral-300">
{scenario.criteria || 'No criteria specified'}
</div>
</div>
<div>
<div className="text-sm font-medium mb-1">Context</div>
<div className="text-sm text-gray-700">
<div className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-1">Context</div>
<div className="text-sm text-gray-700 dark:text-neutral-300">
{scenario.context || 'No context provided'}
</div>
</div>
{result && (
<div>
<div className="text-sm font-medium mb-1">Result Details</div>
<div className="text-sm text-gray-700">
<div className="text-sm font-medium text-gray-600 dark:text-neutral-400 mb-1">Result Details</div>
<div className="text-sm text-gray-700 dark:text-neutral-300">
{result.details}
</div>
</div>

View file

@ -10,6 +10,7 @@ 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";
import { SectionHeader, ListItem } from "../../../../lib/components/structured-list";
type ScenarioType = WithStringId<z.infer<typeof Scenario>>;
@ -72,10 +73,9 @@ export function ScenarioViewer({ scenario, onSave, onClose }: ScenarioViewerProp
</ActionButton>
].filter(Boolean)}
>
<div className="flex flex-col gap-4 p-6 w-full">
<FormSection>
<div className="flex flex-col gap-4">
<FormSection label="Name" showDivider>
<EditableField
label="NAME"
value={editedScenario.name}
onChange={(value) => handleChange('name', value)}
multiline={false}
@ -86,9 +86,8 @@ export function ScenarioViewer({ scenario, onSave, onClose }: ScenarioViewerProp
/>
</FormSection>
<FormSection>
<FormSection label="Description" showDivider>
<EditableField
label="DESCRIPTION"
value={editedScenario.description}
onChange={(value) => handleChange('description', value)}
multiline={true}
@ -98,9 +97,8 @@ export function ScenarioViewer({ scenario, onSave, onClose }: ScenarioViewerProp
/>
</FormSection>
<FormSection>
<FormSection label="Criteria" showDivider>
<EditableField
label="CRITERIA"
value={editedScenario.criteria}
onChange={(value) => handleChange('criteria', value)}
multiline={true}
@ -110,9 +108,8 @@ export function ScenarioViewer({ scenario, onSave, onClose }: ScenarioViewerProp
/>
</FormSection>
<FormSection>
<FormSection label="Context">
<EditableField
label="CONTEXT"
value={editedScenario.context}
onChange={(value) => handleChange('context', value)}
multiline={true}
@ -126,47 +123,6 @@ export function ScenarioViewer({ scenario, onSave, onClose }: ScenarioViewerProp
);
}
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,

View file

@ -1,536 +0,0 @@
'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,7 +1,7 @@
'use client';
import { deleteDataSource } from "../../../../actions/datasource_actions";
import { FormStatusButton } from "../../../../lib/components/FormStatusButton";
import { FormStatusButton } from "../../../../lib/components/form-status-button";
export function DeleteSource({
projectId,

View file

@ -1,5 +1,5 @@
"use client";
import { PageSection } from "../../../../lib/components/PageSection";
import { PageSection } from "../../../../lib/components/page-section";
import { WithStringId } from "../../../../lib/types/types";
import { DataSourceDoc } from "../../../../lib/types/datasource_types";
import { DataSource } from "../../../../lib/types/datasource_types";

View file

@ -1,5 +1,5 @@
"use client";
import { PageSection } from "../../../../lib/components/PageSection";
import { PageSection } from "../../../../lib/components/page-section";
import { WithStringId } from "../../../../lib/types/types";
import { DataSourceDoc } from "../../../../lib/types/datasource_types";
import { DataSource } from "../../../../lib/types/datasource_types";
@ -11,7 +11,7 @@ import { Spinner } from "@nextui-org/react";
import { Pagination } from "@nextui-org/react";
import { ExternalLinkIcon } from "lucide-react";
import { Textarea } from "@nextui-org/react";
import { FormStatusButton } from "../../../../lib/components/FormStatusButton";
import { FormStatusButton } from "../../../../lib/components/form-status-button";
import { PlusIcon } from "lucide-react";
function UrlListItem({

View file

@ -1,7 +1,7 @@
'use client';
import { WithStringId } from "../../../../lib/types/types";
import { DataSource } from "../../../../lib/types/datasource_types";
import { PageSection } from "../../../../lib/components/PageSection";
import { PageSection } from "../../../../lib/components/page-section";
import { ToggleSource } from "../toggle-source";
import { Spinner } from "@nextui-org/react";
import { SourceStatus } from "../source-status";

View file

@ -1,5 +1,5 @@
'use client';
import { FormStatusButton } from "../../../../lib/components/FormStatusButton";
import { FormStatusButton } from "../../../../lib/components/form-status-button";
import { RefreshCwIcon } from "lucide-react";
export function Recrawl({

View file

@ -2,7 +2,7 @@
import { Input, Select, SelectItem, Textarea } from "@nextui-org/react"
import { useState } from "react";
import { createDataSource, addDocsToDataSource } from "../../../../actions/datasource_actions";
import { FormStatusButton } from "../../../../lib/components/FormStatusButton";
import { FormStatusButton } from "../../../../lib/components/form-status-button";
import { DataSourceIcon } from "../../../../lib/components/datasource-icon";
import { PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";

View file

@ -91,7 +91,7 @@ export function SourcesList({
<SelfUpdatingSourceStatus sourceId={source._id} projectId={projectId} initialStatus={source.status} compact={true} />
</td>
<td className="py-4 text-right">
<ToggleSource projectId={projectId} sourceId={source._id} active={source.active} compact={true} />
<ToggleSource projectId={projectId} sourceId={source._id} active={source.active} compact={true} className="bg-default-100" />
</td>
</tr>;
})}

View file

@ -9,11 +9,13 @@ export function ToggleSource({
sourceId,
active,
compact=false,
className
}: {
projectId: string;
sourceId: string;
active: boolean;
compact?: boolean;
className?: string;
}) {
const [loading, setLoading] = useState(false);
const [isActive, setIsActive] = useState(active);
@ -30,12 +32,16 @@ export function ToggleSource({
return <div className="flex flex-col gap-1 items-start">
<div className="flex items-center gap-1">
<Switch
size={compact ? 'sm' : 'md'}
disabled={loading}
size="sm"
isSelected={isActive}
onValueChange={handleActiveSwitchChange}
disabled={loading}
aria-label="Toggle source active state"
classNames={{
wrapper: `light:bg-default-200 dark:bg-default-100 group-data-[selected=true]:bg-primary-500 ${className || ''}`
}}
>
{isActive ? 'Active' : 'Inactive'}
{isActive ? "Active" : "Inactive"}
</Switch>
{loading && <Spinner size="sm" />}
</div>

View file

@ -3,13 +3,14 @@ import { WithStringId } from "../../../lib/types/types";
import { AgenticAPITool } from "../../../lib/types/agents_api_types";
import { WorkflowPrompt, WorkflowAgent, Workflow } from "../../../lib/types/workflow_types";
import { DataSource } from "../../../lib/types/datasource_types";
import { Button, Divider, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Input, Radio, RadioGroup, Select, SelectItem } from "@nextui-org/react";
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Input, Radio, RadioGroup, Divider } from "@nextui-org/react";
import { z } from "zod";
import { DataSourceIcon } from "../../../lib/components/datasource-icon";
import { ActionButton, StructuredPanel } from "../../../lib/components/structured-panel";
import { FormSection } from "../../../lib/components/form-section";
import { EditableField } from "../../../lib/components/editable-field";
import { Label } from "../../../lib/components/label";
import { PlusIcon, SparklesIcon } from "lucide-react";
import { PlusIcon, SparklesIcon, ChevronRight, ChevronDown } from "lucide-react";
import { List } from "./config_list";
import { useState, useEffect, useRef } from "react";
import { usePreviewModal } from "./preview-modal";
@ -18,6 +19,7 @@ import { Textarea } from "@nextui-org/react";
import { PreviewModalProvider } from "./preview-modal";
import { CopilotMessage } from "@/app/lib/types/copilot_types";
import { getCopilotAgentInstructions } from "@/app/actions/copilot_actions";
import { Dropdown as CustomDropdown } from "../../../lib/components/dropdown";
export function AgentConfig({
projectId,
@ -42,6 +44,8 @@ export function AgentConfig({
handleUpdate: (agent: z.infer<typeof WorkflowAgent>) => void,
handleClose: () => void,
}) {
const [isAdvancedConfigOpen, setIsAdvancedConfigOpen] = useState(false);
const atMentions = [];
for (const a of agents) {
if (a.disabled || a.name === agent.name) {
@ -83,85 +87,79 @@ export function AgentConfig({
</ActionButton>
]}>
<div className="flex flex-col gap-4">
{!agent.locked && <>
<EditableField
key="name"
label="Name"
value={agent.name}
onChange={(value) => {
handleUpdate({
...agent,
name: value
});
}}
placeholder="Enter agent name"
validate={(value) => {
if (value.length === 0) {
return { valid: false, errorMessage: "Name cannot be empty" };
}
if (usedAgentNames.has(value)) {
return { valid: false, errorMessage: "This name is already taken" };
}
// validate against this regex: ^[a-zA-Z0-9_-]+$
if (!/^[a-zA-Z0-9_-\s]+$/.test(value)) {
return { valid: false, errorMessage: "Name must contain only letters, numbers, underscores, hyphens, and spaces" };
}
return { valid: true };
}}
/>
<Divider />
</>}
<EditableField
key="description"
label="Description"
value={agent.description || ""}
onChange={(value) => {
handleUpdate({
...agent,
description: value
});
}}
placeholder="Enter a description for this agent"
/>
<Divider />
<div className="flex flex-col gap-1">
<div className="flex justify-between items-center">
<Label label="Instructions" />
<Button
variant="light"
size="sm"
startContent={<SparklesIcon size={16} />}
onPress={() => setShowGenerateModal(true)}
>
Generate
</Button>
</div>
<div className="w-full flex flex-col">
{!agent.locked && (
<FormSection showDivider>
<EditableField
key="instructions"
value={agent.instructions}
key="name"
label="Name"
value={agent.name}
onChange={(value) => {
handleUpdate({
...agent,
instructions: value
name: value
});
}}
markdown
multiline
mentions
mentionsAtValues={atMentions}
placeholder="Enter agent name"
validate={(value) => {
if (value.length === 0) {
return { valid: false, errorMessage: "Name cannot be empty" };
}
if (usedAgentNames.has(value)) {
return { valid: false, errorMessage: "This name is already taken" };
}
if (!/^[a-zA-Z0-9_-\s]+$/.test(value)) {
return { valid: false, errorMessage: "Name must contain only letters, numbers, underscores, hyphens, and spaces" };
}
return { valid: true };
}}
/>
</div>
</div>
</FormSection>
)}
<Divider />
<FormSection showDivider>
<EditableField
key="description"
label="Description"
value={agent.description || ""}
onChange={(value) => {
handleUpdate({
...agent,
description: value
});
}}
placeholder="Enter a description for this agent"
multiline
/>
</FormSection>
<div className="w-full flex flex-col">
<FormSection showDivider>
<EditableField
key="instructions"
label="Instructions"
value={agent.instructions}
onChange={(value) => {
handleUpdate({
...agent,
instructions: value
});
}}
markdown
multiline
mentions
mentionsAtValues={atMentions}
showSaveButton={true}
showDiscardButton={true}
showGenerateButton={{
show: showGenerateModal,
setShow: setShowGenerateModal
}}
/>
</FormSection>
<FormSection showDivider>
<EditableField
key="examples"
label="Examples"
value={agent.examples || ""}
onChange={(value) => {
handleUpdate({
@ -171,131 +169,157 @@ export function AgentConfig({
}}
placeholder="Enter examples for this agent"
markdown
label="Examples"
multiline
mentions
mentionsAtValues={atMentions}
showSaveButton={true}
showDiscardButton={true}
/>
</div>
</FormSection>
<Divider />
<div className="flex flex-col gap-4 items-start">
<Label label="RAG (beta)" />
<List
items={agent.ragDataSources?.map((source) => ({
id: source,
node: <div className="flex items-center gap-1">
<DataSourceIcon type={dataSources.find((ds) => ds._id === source)?.data.type} />
<div>{dataSources.find((ds) => ds._id === source)?.name || "Unknown"}</div>
</div>
})) || []}
onRemove={(id) => {
const newSources = agent.ragDataSources?.filter((s) => s !== id);
handleUpdate({
...agent,
ragDataSources: newSources
});
}}
/>
<Dropdown>
<DropdownTrigger>
<Button
variant="light"
size="sm"
startContent={<PlusIcon size={16} />}
>
Add data source
</Button>
</DropdownTrigger>
<DropdownMenu onAction={(key) => handleUpdate({
...agent,
ragDataSources: [...(agent.ragDataSources || []), key as string]
})}>
{dataSources.filter((ds) => !(agent.ragDataSources || []).includes(ds._id)).map((ds) => (
<DropdownItem
key={ds._id}
startContent={<DataSourceIcon type={ds.data.type} />}
<FormSection label="RAG (beta)" showDivider>
<div className="flex flex-col gap-3">
<Dropdown>
<DropdownTrigger>
<Button
variant="light"
size="sm"
startContent={<PlusIcon size={16} />}
className="w-fit text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
{ds.name}
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
</div>
<Divider />
{agent.ragDataSources !== undefined && agent.ragDataSources.length > 0 && <>
<Label label="Advanced RAG configuration" />
<div className="ml-4 flex flex-col gap-4">
<Label label="Return type" />
<RadioGroup
size="sm"
orientation="horizontal"
value={agent.ragReturnType}
onValueChange={(value) => handleUpdate({
Add data source
</Button>
</DropdownTrigger>
<DropdownMenu onAction={(key) => handleUpdate({
...agent,
ragReturnType: value as z.infer<typeof WorkflowAgent>['ragReturnType']
ragDataSources: [...(agent.ragDataSources || []), key as string]
})}>
{dataSources.filter((ds) => !(agent.ragDataSources || []).includes(ds._id)).map((ds) => (
<DropdownItem
key={ds._id}
startContent={<DataSourceIcon type={ds.data.type} />}
className="text-foreground dark:text-gray-300"
>
{ds.name}
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
<div className="flex flex-col gap-2">
{(agent.ragDataSources || []).map((source) => {
const ds = dataSources.find((ds) => ds._id === source);
return (
<div
key={source}
className="group flex items-center justify-between p-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-750 transition-colors"
>
<div className="flex items-center gap-2">
<div className="p-1 rounded-md bg-white dark:bg-gray-700">
<DataSourceIcon type={ds?.data.type} />
</div>
<span className="text-sm text-gray-700 dark:text-gray-300">
{ds?.name || "Unknown"}
</span>
</div>
<Button
isIconOnly
size="sm"
variant="light"
className="opacity-0 group-hover:opacity-100 transition-opacity text-gray-500 hover:text-red-500"
onClick={() => {
const newSources = agent.ragDataSources?.filter((s) => s !== source);
handleUpdate({
...agent,
ragDataSources: newSources
});
}}
>
<svg className="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</Button>
</div>
);
})}
>
<Radio value="chunks">Chunks</Radio>
<Radio value="content">Content</Radio>
</RadioGroup>
<Label label="No. of matches" />
<Input
variant="bordered"
size="sm"
className="w-20"
value={agent.ragK.toString()}
onValueChange={(value) => handleUpdate({
...agent,
ragK: parseInt(value)
})}
type="number"
/>
</div>
{agent.ragDataSources !== undefined && agent.ragDataSources.length > 0 && (
<>
<button
onClick={() => setIsAdvancedConfigOpen(!isAdvancedConfigOpen)}
className="flex items-center gap-2 text-xs font-medium text-gray-400 dark:text-gray-500 uppercase hover:text-gray-500 dark:hover:text-gray-400"
>
{isAdvancedConfigOpen ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
Advanced RAG configuration
</button>
{isAdvancedConfigOpen && (
<div className="ml-4 flex flex-col gap-4">
<Label label="Return type" />
<RadioGroup
size="sm"
orientation="horizontal"
value={agent.ragReturnType}
onValueChange={(value) => handleUpdate({
...agent,
ragReturnType: value as z.infer<typeof WorkflowAgent>['ragReturnType']
})}
classNames={{
label: "text-foreground dark:text-gray-300"
}}
>
<Radio value="chunks">Chunks</Radio>
<Radio value="content">Content</Radio>
</RadioGroup>
<Label label="No. of matches" />
<Input
variant="bordered"
size="sm"
className="w-20 text-foreground dark:text-gray-300"
value={agent.ragK.toString()}
onValueChange={(value) => handleUpdate({
...agent,
ragK: parseInt(value)
})}
type="number"
/>
</div>
)}
</>
)}
</div>
<Divider />
</>}
</FormSection>
<Divider />
<div className="flex flex-col gap-2 items-start">
<Label label="Model" />
<Select
variant="bordered"
selectedKeys={[agent.model]}
size="sm"
onSelectionChange={(keys) => handleUpdate({
<FormSection label="Model" showDivider>
<CustomDropdown
value={agent.model}
options={WorkflowAgent.shape.model.options.map((model) => ({
key: model.value,
label: model.value
}))}
onChange={(value) => handleUpdate({
...agent,
model: keys.currentKey! as z.infer<typeof WorkflowAgent>['model']
model: value as z.infer<typeof WorkflowAgent>['model']
})}
className="w-40"
>
{WorkflowAgent.shape.model.options.map((model) => (
<SelectItem key={model.value} value={model.value}>{model.value}</SelectItem>
))}
</Select>
</div>
/>
</FormSection>
<Divider />
<div className="flex flex-col gap-2 items-start">
<Label label="Conversation control after turn" />
<Select
variant="bordered"
selectedKeys={[agent.controlType]}
size="sm"
onSelectionChange={(keys) => handleUpdate({
<FormSection label="Conversation control after turn">
<CustomDropdown
value={agent.controlType}
options={[
{ key: "retain", label: "Retain control" },
{ key: "relinquish_to_parent", label: "Relinquish to parent" },
{ key: "relinquish_to_start", label: "Relinquish to 'start' agent" }
]}
onChange={(value) => handleUpdate({
...agent,
controlType: keys.currentKey! as z.infer<typeof WorkflowAgent>['controlType']
controlType: value as z.infer<typeof WorkflowAgent>['controlType']
})}
className="w-60"
>
<SelectItem key="retain" value="retain">Retain control</SelectItem>
<SelectItem key="relinquish_to_parent" value="relinquish_to_parent">Relinquish to parent</SelectItem>
<SelectItem key="relinquish_to_start" value="relinquish_to_start">Relinquish to &apos;start&apos; agent</SelectItem>
</Select>
</div>
/>
</FormSection>
<Divider />

View file

@ -7,6 +7,7 @@ import { useRef, useEffect } from "react";
import { ActionButton, StructuredPanel } from "../../../lib/components/structured-panel";
import clsx from "clsx";
import { EllipsisVerticalIcon } from "lucide-react";
import { SectionHeader, ListItem } from "../../../lib/components/structured-list";
interface EntityListProps {
agents: z.infer<typeof WorkflowAgent>[];
@ -30,54 +31,6 @@ interface EntityListProps {
onDeletePrompt: (name: string) => void;
}
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,
disabled,
rightElement,
selectedRef
}: {
name: string;
isSelected: boolean;
onClick: () => void;
disabled?: boolean;
rightElement?: React.ReactNode;
selectedRef?: React.RefObject<HTMLButtonElement>;
}) {
return (
<button
ref={selectedRef as any}
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={clsx("truncate text-sm", {
"text-gray-400": disabled,
})}>{name}</div>
{rightElement}
</button>
);
}
export function EntityList({
agents,
tools,

View file

@ -708,7 +708,7 @@ export function WorkflowEditor({
return <div className="flex flex-col h-full relative">
<div className="shrink-0 flex justify-between items-center pb-2">
<div className="flex items-center gap-1 border-1 border-gray-200 rounded-md px-2 text-gray-800">
<div className="workflow-version-selector flex items-center gap-1 px-2 text-gray-800 dark:text-gray-100">
<WorkflowIcon size={16} />
<EditableField
key={state.present.workflow._id}
@ -716,6 +716,7 @@ export function WorkflowEditor({
onChange={handleRenameWorkflow}
placeholder="Name this version"
className="text-sm font-semibold"
inline={true}
/>
{state.present.publishing && <Spinner size="sm" />}
{isLive && <PublishedBadge />}

View file

@ -2,6 +2,7 @@ import logo from "@/public/rowboat-logo.png";
import Image from "next/image";
import Link from "next/link";
import { UserButton } from "../lib/components/user_button";
import { ThemeToggle } from "../lib/components/theme-toggle";
export default function Layout({
children,
@ -9,17 +10,20 @@ export default function Layout({
children: React.ReactNode;
}>) {
return <>
<header className="shrink-0 flex justify-between items-center px-4 py-2 border-b border-b-gray-100">
<header className="shrink-0 flex justify-between items-center px-4 py-2 border-b border-border bg-background">
<div className="flex items-center gap-12">
<Link href="/">
<Image
src={logo}
height={24}
alt="RowBoat Labs Logo"
/>
<Image
src={logo}
height={24}
alt="RowBoat Labs Logo"
/>
</Link>
</div>
<UserButton />
<div className="flex items-center gap-2">
<ThemeToggle />
<UserButton />
</div>
</header>
<main className="grow overflow-auto">
{children}

View file

@ -3,7 +3,7 @@ import { cn, Input } from "@nextui-org/react";
import { createProject } from "../../actions/project_actions";
import { templates } from "../../lib/project_templates";
import { WorkflowTemplate } from "../../lib/types/workflow_types";
import { FormStatusButton } from "../../lib/components/FormStatusButton";
import { FormStatusButton } from "../../lib/components/form-status-button";
import { useFormStatus } from "react-dom";
import { z } from "zod";
import { useState } from "react";

View file

@ -0,0 +1,48 @@
'use client'
import { createContext, useContext, useEffect, useState } from 'react'
type Theme = 'dark' | 'light'
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: Theme
}
type ThemeProviderState = {
theme: Theme
toggleTheme: () => void
}
const ThemeProviderContext = createContext<ThemeProviderState | undefined>(undefined)
export function ThemeProvider({
children,
defaultTheme = 'light',
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(defaultTheme)
useEffect(() => {
const root = window.document.documentElement
root.classList.remove('light', 'dark')
root.classList.add(theme)
}, [theme])
const toggleTheme = () => {
setTheme(theme === 'light' ? 'dark' : 'light')
}
return (
<ThemeProviderContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeProviderContext.Provider>
)
}
export function useTheme() {
const context = useContext(ThemeProviderContext)
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider')
}
return context
}