mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-03 19:25:19 +02:00
Update tools UI and consolidate editable fields over heroUI (#185)
* Make UI UX fixes to tools and tool configs * Fix font sizing and dark mode issues for tool labels * Use heroUI input fields and consolidate editable fields into input-field * Add auto focus to instructions and examples for agents
This commit is contained in:
parent
86fb4824b2
commit
4fd06f9761
10 changed files with 807 additions and 579 deletions
|
|
@ -1,204 +0,0 @@
|
|||
import { Button, Input, Textarea } from "@heroui/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useClickAway } from "../../../hooks/use-click-away";
|
||||
import MarkdownContent from "./markdown-content";
|
||||
import clsx from "clsx";
|
||||
import { Label } from "./label";
|
||||
import { SparklesIcon } from "lucide-react";
|
||||
|
||||
interface EditableFieldProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
markdown?: boolean;
|
||||
multiline?: boolean;
|
||||
locked?: boolean;
|
||||
className?: string;
|
||||
validate?: (value: string) => { valid: boolean; errorMessage?: string };
|
||||
light?: boolean;
|
||||
error?: string | null;
|
||||
inline?: boolean;
|
||||
showGenerateButton?: {
|
||||
show: boolean;
|
||||
setShow: (show: boolean) => void;
|
||||
};
|
||||
disabled?: boolean;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export function EditableField({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
placeholder = "Click to edit...",
|
||||
markdown = false,
|
||||
multiline = false,
|
||||
locked = false,
|
||||
className = "flex flex-col gap-1 w-full",
|
||||
validate,
|
||||
light = false,
|
||||
error,
|
||||
inline = false,
|
||||
showGenerateButton,
|
||||
disabled = false,
|
||||
type = "text",
|
||||
}: EditableFieldProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const validationResult = validate?.(localValue);
|
||||
const isValid = !validate || validationResult?.valid;
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
useClickAway(ref, () => {
|
||||
if (isEditing) {
|
||||
if (isValid && localValue !== value) {
|
||||
onChange(localValue);
|
||||
} else {
|
||||
setLocalValue(value);
|
||||
}
|
||||
setIsEditing(false);
|
||||
}
|
||||
});
|
||||
|
||||
const onValueChange = (newValue: string) => {
|
||||
setLocalValue(newValue);
|
||||
onChange(newValue); // Always save immediately
|
||||
};
|
||||
|
||||
const commonProps = {
|
||||
autoFocus: true,
|
||||
value: localValue,
|
||||
onValueChange: onValueChange,
|
||||
variant: "bordered" as const,
|
||||
labelPlacement: "outside" as const,
|
||||
placeholder: markdown ? '' : placeholder,
|
||||
classNames: {
|
||||
input: "rounded-md",
|
||||
inputWrapper: "rounded-md border-medium"
|
||||
},
|
||||
radius: "md" as const,
|
||||
isInvalid: !isValid,
|
||||
errorMessage: validationResult?.errorMessage,
|
||||
onKeyDown: (e: React.KeyboardEvent) => {
|
||||
if (!multiline && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (isValid && localValue !== value) {
|
||||
onChange(localValue);
|
||||
}
|
||||
setIsEditing(false);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setLocalValue(value);
|
||||
setIsEditing(false);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{multiline && <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}
|
||||
type={type}
|
||||
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} />
|
||||
</div>}
|
||||
{!markdown && <div className={`${multiline ? 'whitespace-pre-wrap max-h-[420px] overflow-y-auto' : 'flex items-center'}`}>
|
||||
{value}
|
||||
</div>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{markdown && <div className="max-h-[420px] overflow-y-auto text-gray-400">
|
||||
<MarkdownContent content={placeholder} />
|
||||
</div>}
|
||||
{!markdown && <span className="text-gray-400">{placeholder}</span>}
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-xs text-red-500 mt-1">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,278 +0,0 @@
|
|||
import { Button, Input, InputProps, Kbd, Textarea } from "@heroui/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useClickAway } from "../../../hooks/use-click-away";
|
||||
import MarkdownContent from "./markdown-content";
|
||||
import clsx from "clsx";
|
||||
import { Label } from "./label";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Match } from "./mentions_editor";
|
||||
import { SparklesIcon } from "lucide-react";
|
||||
import { EntitySelectionContext } from "../../projects/[projectId]/workflow/workflow_editor";
|
||||
import { useContext } from "react";
|
||||
const MentionsEditor = dynamic(() => import('./mentions_editor'), { ssr: false });
|
||||
|
||||
interface EditableFieldProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
markdown?: boolean;
|
||||
multiline?: boolean;
|
||||
locked?: boolean;
|
||||
className?: string;
|
||||
validate?: (value: string) => { valid: boolean; errorMessage?: string };
|
||||
light?: boolean;
|
||||
mentions?: boolean;
|
||||
mentionsAtValues?: Match[];
|
||||
showSaveButton?: boolean;
|
||||
showDiscardButton?: boolean;
|
||||
error?: string | null;
|
||||
inline?: boolean;
|
||||
showGenerateButton?: {
|
||||
show: boolean;
|
||||
setShow: (show: boolean) => void;
|
||||
};
|
||||
onMentionNavigate?: (type: 'agent' | 'tool' | 'prompt', name: string) => void;
|
||||
}
|
||||
|
||||
export function EditableField({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
placeholder = "Click to edit...",
|
||||
markdown = false,
|
||||
multiline = false,
|
||||
locked = false,
|
||||
className = "flex flex-col gap-1 w-full",
|
||||
validate,
|
||||
light = false,
|
||||
mentions = false,
|
||||
mentionsAtValues = [],
|
||||
showSaveButton = false,
|
||||
showDiscardButton = false,
|
||||
error,
|
||||
inline = false,
|
||||
showGenerateButton,
|
||||
onMentionNavigate,
|
||||
}: EditableFieldProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Use the context directly, will be undefined if not in provider
|
||||
const entitySelection = useContext(EntitySelectionContext);
|
||||
|
||||
const validationResult = validate?.(localValue);
|
||||
const isValid = !validate || validationResult?.valid;
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
useClickAway(ref, () => {
|
||||
if (isEditing) {
|
||||
if (isValid && localValue !== value) {
|
||||
onChange(localValue);
|
||||
} else {
|
||||
setLocalValue(value);
|
||||
}
|
||||
}
|
||||
setIsEditing(false);
|
||||
});
|
||||
|
||||
const handleMentionNavigate = onMentionNavigate || ((type, name) => {
|
||||
if (entitySelection) {
|
||||
if (type === 'agent') entitySelection.onSelectAgent(name);
|
||||
else if (type === 'tool') entitySelection.onSelectTool(name);
|
||||
else if (type === 'prompt') entitySelection.onSelectPrompt(name);
|
||||
}
|
||||
});
|
||||
|
||||
const commonProps = {
|
||||
autoFocus: true,
|
||||
value: localValue,
|
||||
onValueChange: setLocalValue,
|
||||
variant: "bordered" as const,
|
||||
labelPlacement: "outside" as const,
|
||||
placeholder: markdown ? '' : placeholder,
|
||||
classNames: {
|
||||
input: "rounded-md",
|
||||
inputWrapper: "rounded-md border-medium"
|
||||
},
|
||||
radius: "md" as const,
|
||||
isInvalid: !isValid,
|
||||
errorMessage: validationResult?.errorMessage,
|
||||
onKeyDown: (e: React.KeyboardEvent) => {
|
||||
if (!multiline && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (isValid && localValue !== value) {
|
||||
onChange(localValue);
|
||||
}
|
||||
setIsEditing(false);
|
||||
}
|
||||
/* DISABLE shift+enter save for multiline fields
|
||||
if (multiline && e.key === "Enter" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (isValid && localValue !== value) {
|
||||
onChange(localValue);
|
||||
}
|
||||
setIsEditing(false);
|
||||
}
|
||||
*/
|
||||
if (e.key === "Escape") {
|
||||
setLocalValue(value);
|
||||
setIsEditing(false);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
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 text-sm focus-visible:ring-0 focus:ring-0 outline-none"
|
||||
classNames={{
|
||||
...commonProps.classNames,
|
||||
input: "rounded-md py-2 text-base focus-visible:ring-0 focus:ring-0 outline-none",
|
||||
inputWrapper: "rounded-md border-medium py-1"
|
||||
}}
|
||||
/>}
|
||||
{!multiline && <Input
|
||||
{...commonProps}
|
||||
className="w-full text-sm focus-visible:ring-0 focus:ring-0 outline-none"
|
||||
classNames={{
|
||||
...commonProps.classNames,
|
||||
input: clsx("rounded-md py-2 text-base focus-visible:ring-0 focus:ring-0 outline-none", {
|
||||
"border-0 focus:outline-none pl-2": inline
|
||||
}),
|
||||
inputWrapper: clsx("rounded-md border-medium py-1", {
|
||||
"border-0 bg-transparent": inline
|
||||
})
|
||||
}}
|
||||
/>}
|
||||
</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(
|
||||
"rounded-md border border-gray-200 dark:border-gray-700 px-2 py-1 min-h-[40px] text-sm",
|
||||
{
|
||||
"whitespace-pre-wrap": multiline,
|
||||
"flex items-center": !multiline,
|
||||
"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>
|
||||
<MarkdownContent content={value} atValues={mentionsAtValues} onMentionNavigate={handleMentionNavigate} />
|
||||
</div>}
|
||||
{!markdown && <div className={multiline ? 'whitespace-pre-wrap' : 'flex items-center'}>
|
||||
<MarkdownContent content={value} atValues={mentionsAtValues} onMentionNavigate={handleMentionNavigate} />
|
||||
</div>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{markdown && <div className="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>
|
||||
);
|
||||
}
|
||||
588
apps/rowboat/app/lib/components/input-field.tsx
Normal file
588
apps/rowboat/app/lib/components/input-field.tsx
Normal file
|
|
@ -0,0 +1,588 @@
|
|||
import { Button, Input, Textarea, Chip, Select, SelectItem, Checkbox } from "@heroui/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useClickAway } from "../../../hooks/use-click-away";
|
||||
import MarkdownContent from "./markdown-content";
|
||||
import clsx from "clsx";
|
||||
import { Label } from "./label";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Match } from "./mentions_editor";
|
||||
import { SparklesIcon, Edit3Icon, XIcon, CheckIcon } from "lucide-react";
|
||||
import { EntitySelectionContext } from "../../projects/[projectId]/workflow/workflow_editor";
|
||||
import { useContext } from "react";
|
||||
|
||||
const MentionsEditor = dynamic(() => import('./mentions_editor'), { ssr: false });
|
||||
|
||||
// Base InputField interface
|
||||
interface BaseInputFieldProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
validate?: (value: string) => { valid: boolean; errorMessage?: string };
|
||||
error?: string | null;
|
||||
disabled?: boolean;
|
||||
locked?: boolean;
|
||||
inline?: boolean;
|
||||
showGenerateButton?: {
|
||||
show: boolean;
|
||||
setShow: (show: boolean) => void;
|
||||
};
|
||||
onMentionNavigate?: (type: 'agent' | 'tool' | 'prompt', name: string) => void;
|
||||
}
|
||||
|
||||
// Text input specific props
|
||||
interface TextInputFieldProps extends BaseInputFieldProps {
|
||||
type: 'text';
|
||||
multiline?: boolean;
|
||||
markdown?: boolean;
|
||||
mentions?: boolean;
|
||||
mentionsAtValues?: Match[];
|
||||
showSaveButton?: boolean;
|
||||
showDiscardButton?: boolean;
|
||||
immediateSave?: boolean;
|
||||
}
|
||||
|
||||
// Select input specific props
|
||||
interface SelectInputFieldProps extends BaseInputFieldProps {
|
||||
type: 'select';
|
||||
options: { key: string; label: string; disabled?: boolean }[];
|
||||
selectedKeys?: Set<string>;
|
||||
onSelectionChange: (keys: any) => void;
|
||||
}
|
||||
|
||||
// Checkbox input specific props
|
||||
interface CheckboxInputFieldProps extends BaseInputFieldProps {
|
||||
type: 'checkbox';
|
||||
isSelected?: boolean;
|
||||
onValueChange?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
// Number input specific props
|
||||
interface NumberInputFieldProps extends BaseInputFieldProps {
|
||||
type: 'number';
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
immediateSave?: boolean;
|
||||
}
|
||||
|
||||
// Union type for all input field types
|
||||
type InputFieldProps = TextInputFieldProps | SelectInputFieldProps | CheckboxInputFieldProps | NumberInputFieldProps;
|
||||
|
||||
export function InputField(props: InputFieldProps) {
|
||||
// Handle different input types
|
||||
if (props.type === 'select') {
|
||||
return <SelectInputField {...props} />;
|
||||
}
|
||||
|
||||
if (props.type === 'checkbox') {
|
||||
return <CheckboxInputField {...props} />;
|
||||
}
|
||||
|
||||
if (props.type === 'number') {
|
||||
return <NumberInputField {...props} />;
|
||||
}
|
||||
|
||||
// Default to text input
|
||||
return <TextInputField {...props} />;
|
||||
}
|
||||
|
||||
// Text Input Field Component
|
||||
function TextInputField({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
placeholder = "Click to edit...",
|
||||
className = "flex flex-col gap-1 w-full",
|
||||
validate,
|
||||
error,
|
||||
disabled = false,
|
||||
locked = false,
|
||||
inline = false,
|
||||
showGenerateButton,
|
||||
onMentionNavigate,
|
||||
multiline = false,
|
||||
markdown = false,
|
||||
mentions = false,
|
||||
mentionsAtValues = [],
|
||||
showSaveButton = false,
|
||||
showDiscardButton = false,
|
||||
immediateSave = false,
|
||||
}: TextInputFieldProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Use the context directly, will be undefined if not in provider
|
||||
const entitySelection = useContext(EntitySelectionContext);
|
||||
|
||||
const validationResult = validate?.(localValue);
|
||||
const isValid = !validate || validationResult?.valid;
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
useClickAway(ref, () => {
|
||||
if (isEditing) {
|
||||
if (immediateSave) {
|
||||
if (isValid && localValue !== value) {
|
||||
onChange(localValue);
|
||||
}
|
||||
} else {
|
||||
if (isValid && localValue !== value) {
|
||||
onChange(localValue);
|
||||
} else {
|
||||
setLocalValue(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
setIsEditing(false);
|
||||
});
|
||||
|
||||
const handleMentionNavigate = onMentionNavigate || ((type, name) => {
|
||||
if (entitySelection) {
|
||||
if (type === 'agent') entitySelection.onSelectAgent(name);
|
||||
else if (type === 'tool') entitySelection.onSelectTool(name);
|
||||
else if (type === 'prompt') entitySelection.onSelectPrompt(name);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
if (isValid && localValue !== value) {
|
||||
onChange(localValue);
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
setLocalValue(value);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!multiline && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (immediateSave) {
|
||||
if (isValid && localValue !== value) {
|
||||
onChange(localValue);
|
||||
}
|
||||
} else {
|
||||
handleSave();
|
||||
}
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
handleDiscard();
|
||||
}
|
||||
};
|
||||
|
||||
const onValueChange = (newValue: string) => {
|
||||
setLocalValue(newValue);
|
||||
if (immediateSave) {
|
||||
onChange(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
// Determine input size based on content length and multiline
|
||||
const getInputSize = () => {
|
||||
if (multiline) {
|
||||
if (localValue.length > 1000) return "lg";
|
||||
if (localValue.length > 500) return "md";
|
||||
return "sm";
|
||||
}
|
||||
return "sm";
|
||||
};
|
||||
|
||||
// Determine if we should show action buttons
|
||||
const hasChanges = localValue !== value;
|
||||
const showActions = hasChanges && (showSaveButton || showDiscardButton);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div ref={ref} className={clsx("flex flex-col gap-2 w-full", className)}>
|
||||
{/* Header with label and action buttons */}
|
||||
{(label || showGenerateButton || showActions) && (
|
||||
<div className="flex justify-between items-center">
|
||||
{label && <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>
|
||||
)}
|
||||
{showActions && (
|
||||
<>
|
||||
{showDiscardButton && (
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
onPress={handleDiscard}
|
||||
startContent={<XIcon size={16} />}
|
||||
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={handleSave}
|
||||
startContent={<CheckIcon size={16} />}
|
||||
isDisabled={!isValid}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input field */}
|
||||
{mentions ? (
|
||||
<div className="w-full">
|
||||
<MentionsEditor
|
||||
atValues={mentionsAtValues}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onValueChange={setLocalValue}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : multiline ? (
|
||||
<Textarea
|
||||
value={localValue}
|
||||
onValueChange={onValueChange}
|
||||
placeholder={placeholder}
|
||||
variant="bordered"
|
||||
size={getInputSize()}
|
||||
minRows={3}
|
||||
maxRows={20}
|
||||
isInvalid={!isValid}
|
||||
errorMessage={validationResult?.errorMessage}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
classNames={{
|
||||
input: "text-sm focus:outline-none focus:ring-0",
|
||||
inputWrapper: "border-gray-200 dark:border-gray-700 focus-within:ring-0 focus-within:outline-none",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={localValue}
|
||||
onValueChange={onValueChange}
|
||||
placeholder={placeholder}
|
||||
variant="bordered"
|
||||
size="sm"
|
||||
isInvalid={!isValid}
|
||||
errorMessage={validationResult?.errorMessage}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
classNames={{
|
||||
input: "text-sm focus:outline-none focus:ring-0",
|
||||
inputWrapper: clsx("border-gray-200 dark:border-gray-700 focus-within:ring-0 focus-within:outline-none", {
|
||||
"border-0 bg-transparent": inline
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Read-only view
|
||||
return (
|
||||
<div ref={ref} className={clsx("w-full", className)}>
|
||||
{/* Header with label and generate button */}
|
||||
{(label || showGenerateButton) && (
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
{label && <Label label={label} />}
|
||||
{showGenerateButton && (
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
startContent={<SparklesIcon size={16} />}
|
||||
onPress={() => showGenerateButton.setShow(true)}
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content display */}
|
||||
<div
|
||||
className={clsx(
|
||||
"group relative rounded-lg border border-gray-200 dark:border-gray-700 p-3 min-h-[40px] transition-all duration-200",
|
||||
{
|
||||
"cursor-pointer hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800": !locked && !disabled,
|
||||
"cursor-not-allowed opacity-60": locked || disabled,
|
||||
"border-0 bg-transparent p-0": inline,
|
||||
}
|
||||
)}
|
||||
onClick={() => !locked && !disabled && setIsEditing(true)}
|
||||
>
|
||||
{/* Content */}
|
||||
<div className={clsx("text-sm", {
|
||||
"whitespace-pre-wrap": multiline,
|
||||
"flex items-center": !multiline,
|
||||
})}>
|
||||
{value ? (
|
||||
<>
|
||||
{markdown ? (
|
||||
<div className={clsx("prose prose-sm max-w-none", {
|
||||
"max-h-[420px] overflow-y-auto": multiline
|
||||
})}>
|
||||
<MarkdownContent
|
||||
content={value}
|
||||
atValues={mentionsAtValues}
|
||||
onMentionNavigate={handleMentionNavigate}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={clsx({
|
||||
"whitespace-pre-wrap": multiline,
|
||||
"max-h-[420px] overflow-y-auto": multiline
|
||||
})}>
|
||||
<MarkdownContent
|
||||
content={value}
|
||||
atValues={mentionsAtValues}
|
||||
onMentionNavigate={handleMentionNavigate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{markdown ? (
|
||||
<div className="text-gray-400 prose prose-sm max-w-none">
|
||||
<MarkdownContent content={placeholder} atValues={mentionsAtValues} />
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400">{placeholder}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="text-xs text-red-500 mt-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Select Input Field Component
|
||||
function SelectInputField({
|
||||
label,
|
||||
options,
|
||||
selectedKeys,
|
||||
onSelectionChange,
|
||||
className = "flex flex-col gap-1 w-full",
|
||||
disabled = false,
|
||||
locked = false,
|
||||
}: SelectInputFieldProps) {
|
||||
return (
|
||||
<div className={clsx("w-full", className)}>
|
||||
{label && (
|
||||
<div className="mb-2">
|
||||
<Label label={label} />
|
||||
</div>
|
||||
)}
|
||||
<Select
|
||||
variant="bordered"
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={onSelectionChange}
|
||||
isDisabled={disabled || locked}
|
||||
size="sm"
|
||||
classNames={{
|
||||
trigger: "border-gray-200 dark:border-gray-700 focus-within:ring-0 focus-within:outline-none",
|
||||
}}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.key}
|
||||
isDisabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Checkbox Input Field Component
|
||||
function CheckboxInputField({
|
||||
label,
|
||||
isSelected = false,
|
||||
onValueChange,
|
||||
className = "flex flex-col gap-1 w-full",
|
||||
disabled = false,
|
||||
locked = false,
|
||||
}: CheckboxInputFieldProps) {
|
||||
return (
|
||||
<div className={clsx("w-full", className)}>
|
||||
<Checkbox
|
||||
isSelected={isSelected}
|
||||
onValueChange={onValueChange}
|
||||
isDisabled={disabled || locked}
|
||||
size="sm"
|
||||
>
|
||||
{label && <span className="text-sm">{label}</span>}
|
||||
</Checkbox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Number Input Field Component
|
||||
function NumberInputField({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
placeholder = "Enter number...",
|
||||
className = "flex flex-col gap-1 w-full",
|
||||
validate,
|
||||
error,
|
||||
disabled = false,
|
||||
locked = false,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
immediateSave = false,
|
||||
}: NumberInputFieldProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const validationResult = validate?.(localValue);
|
||||
const isValid = !validate || validationResult?.valid;
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
useClickAway(ref, () => {
|
||||
if (isEditing) {
|
||||
if (immediateSave) {
|
||||
if (isValid && localValue !== value) {
|
||||
onChange(localValue);
|
||||
}
|
||||
} else {
|
||||
if (isValid && localValue !== value) {
|
||||
onChange(localValue);
|
||||
} else {
|
||||
setLocalValue(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
setIsEditing(false);
|
||||
});
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (immediateSave) {
|
||||
if (isValid && localValue !== value) {
|
||||
onChange(localValue);
|
||||
}
|
||||
} else {
|
||||
if (isValid && localValue !== value) {
|
||||
onChange(localValue);
|
||||
}
|
||||
setIsEditing(false);
|
||||
}
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setLocalValue(value);
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onValueChange = (newValue: string) => {
|
||||
setLocalValue(newValue);
|
||||
if (immediateSave) {
|
||||
onChange(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div ref={ref} className={clsx("flex flex-col gap-2 w-full", className)}>
|
||||
{label && (
|
||||
<div className="mb-2">
|
||||
<Label label={label} />
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
value={localValue}
|
||||
onValueChange={onValueChange}
|
||||
placeholder={placeholder}
|
||||
variant="bordered"
|
||||
size="sm"
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
isInvalid={!isValid}
|
||||
errorMessage={validationResult?.errorMessage}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
classNames={{
|
||||
input: "text-sm focus:outline-none focus:ring-0",
|
||||
inputWrapper: "border-gray-200 dark:border-gray-700 focus-within:ring-0 focus-within:outline-none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Read-only view
|
||||
return (
|
||||
<div ref={ref} className={clsx("w-full", className)}>
|
||||
{label && (
|
||||
<div className="mb-2">
|
||||
<Label label={label} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
"group relative rounded-lg border border-gray-200 dark:border-gray-700 p-3 min-h-[40px] transition-all duration-200",
|
||||
{
|
||||
"cursor-pointer hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800": !locked && !disabled,
|
||||
"cursor-not-allowed opacity-60": locked || disabled,
|
||||
}
|
||||
)}
|
||||
onClick={() => !locked && !disabled && setIsEditing(true)}
|
||||
>
|
||||
{/* Content */}
|
||||
<div className="text-sm flex items-center">
|
||||
{value ? (
|
||||
<span>{value}</span>
|
||||
) : (
|
||||
<span className="text-gray-400">{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="text-xs text-red-500 mt-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -87,11 +87,13 @@ export default function MentionEditor({
|
|||
value,
|
||||
placeholder,
|
||||
onValueChange,
|
||||
autoFocus = false,
|
||||
}: {
|
||||
atValues: Match[];
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
autoFocus?: boolean;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const quillRef = useRef<Quill | null>(null);
|
||||
|
|
@ -175,6 +177,13 @@ export default function MentionEditor({
|
|||
}
|
||||
});
|
||||
quillRef.current = quill;
|
||||
|
||||
// Auto-focus if requested
|
||||
if (autoFocus) {
|
||||
setTimeout(() => {
|
||||
quill.focus();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
|
|
@ -184,7 +193,7 @@ export default function MentionEditor({
|
|||
quillRef.current.off(Quill.events.TEXT_CHANGE);
|
||||
}
|
||||
}
|
||||
}, [atValues, onValueChange, placeholder, value]);
|
||||
}, [atValues, onValueChange, placeholder, value, autoFocus]);
|
||||
|
||||
return <div className="relative">
|
||||
<button className="absolute top-2 right-2 z-10">
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Spinner, Textarea, Button, Dropdown, DropdownMenu, DropdownItem, Dropdo
|
|||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { getProjectConfig, updateProjectName, updateWebhookUrl, createApiKey, deleteApiKey, listApiKeys, deleteProject, rotateSecret } from "../../../actions/project_actions";
|
||||
import { CopyButton } from "../../../../components/common/copy-button";
|
||||
import { EditableField } from "../../../lib/components/editable-field";
|
||||
import { InputField } from "../../../lib/components/input-field";
|
||||
import { EyeIcon, EyeOffIcon, Settings, Plus, MoreVertical } from "lucide-react";
|
||||
import { WithStringId } from "../../../lib/types/types";
|
||||
import { ApiKey } from "../../../lib/types/project_types";
|
||||
|
|
@ -84,7 +84,7 @@ export function BasicSettingsSection({
|
|||
return <Section title="Basic settings">
|
||||
<FormSection label="Project name">
|
||||
{loading && <Spinner size="sm" />}
|
||||
{!loading && <EditableField
|
||||
{!loading && <InputField type="text"
|
||||
value={projectName || ''}
|
||||
onChange={updateName}
|
||||
className="w-full"
|
||||
|
|
@ -374,7 +374,7 @@ export function WebhookUrlSection({
|
|||
<Divider />
|
||||
<FormSection label="Webhook URL">
|
||||
{loading && <Spinner size="sm" />}
|
||||
{!loading && <EditableField
|
||||
{!loading && <InputField type="text"
|
||||
value={webhookUrl || ''}
|
||||
onChange={update}
|
||||
validate={validate}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||
import { Panel } from "@/components/common/panel-common";
|
||||
import { Button as CustomButton } from "@/components/ui/button";
|
||||
import clsx from "clsx";
|
||||
import { EditableField } from "@/app/lib/components/editable-field";
|
||||
import { InputField } from "@/app/lib/components/input-field";
|
||||
import { USE_TRANSFER_CONTROL_OPTIONS } from "@/app/lib/feature_flags";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Info } from "lucide-react";
|
||||
|
|
@ -76,6 +76,7 @@ export function AgentConfig({
|
|||
const [previousRagSources, setPreviousRagSources] = useState<string[]>([]);
|
||||
const [billingError, setBillingError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
const [showSavedBanner, setShowSavedBanner] = useState(false);
|
||||
|
||||
const {
|
||||
start: startCopilotChat,
|
||||
|
|
@ -86,6 +87,12 @@ export function AgentConfig({
|
|||
dataSources
|
||||
});
|
||||
|
||||
// Function to show saved banner
|
||||
const showSavedMessage = () => {
|
||||
setShowSavedBanner(true);
|
||||
setTimeout(() => setShowSavedBanner(false), 2000);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLocalName(agent.name);
|
||||
}, [agent.name]);
|
||||
|
|
@ -202,6 +209,16 @@ export function AgentConfig({
|
|||
}
|
||||
>
|
||||
<div className="flex flex-col gap-6 p-4 h-[calc(100vh-100px)] min-h-0 flex-1">
|
||||
{/* Saved Banner */}
|
||||
{showSavedBanner && (
|
||||
<div className="absolute top-4 right-4 z-10 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 animate-in slide-in-from-top-2 duration-300">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Changes saved</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
||||
{(['instructions', 'configurations'] as TabType[]).map((tab) => (
|
||||
|
|
@ -227,6 +244,15 @@ export function AgentConfig({
|
|||
{isInstructionsMaximized ? (
|
||||
<div className="fixed inset-0 z-50 bg-white dark:bg-gray-900">
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Saved Banner for maximized instructions */}
|
||||
{showSavedBanner && (
|
||||
<div className="absolute top-4 right-4 z-10 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 animate-in slide-in-from-top-2 duration-300">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Changes saved</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{agent.name}</span>
|
||||
|
|
@ -243,7 +269,8 @@ export function AgentConfig({
|
|||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden p-4">
|
||||
<EditableField
|
||||
<InputField
|
||||
type="text"
|
||||
key="instructions-maximized"
|
||||
value={agent.instructions}
|
||||
onChange={(value) => {
|
||||
|
|
@ -251,13 +278,12 @@ export function AgentConfig({
|
|||
...agent,
|
||||
instructions: value
|
||||
});
|
||||
showSavedMessage();
|
||||
}}
|
||||
markdown
|
||||
multiline
|
||||
mentions
|
||||
mentionsAtValues={atMentions}
|
||||
showSaveButton={true}
|
||||
showDiscardButton={true}
|
||||
className="h-full min-h-0 overflow-auto"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -292,7 +318,13 @@ export function AgentConfig({
|
|||
Generate
|
||||
</CustomButton>
|
||||
</div>
|
||||
<EditableField
|
||||
{!isInstructionsMaximized && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
💡 Tip: Use the maximized view for a better editing experience
|
||||
</div>
|
||||
)}
|
||||
<InputField
|
||||
type="text"
|
||||
key="instructions"
|
||||
value={agent.instructions}
|
||||
onChange={(value) => {
|
||||
|
|
@ -300,20 +332,19 @@ export function AgentConfig({
|
|||
...agent,
|
||||
instructions: value
|
||||
});
|
||||
showSavedMessage();
|
||||
}}
|
||||
markdown
|
||||
multiline
|
||||
mentions
|
||||
mentionsAtValues={atMentions}
|
||||
showSaveButton={true}
|
||||
showDiscardButton={true}
|
||||
className="h-full min-h-0 overflow-auto !mb-0 !mt-0"
|
||||
/>
|
||||
</div>
|
||||
{/* Examples Section */}
|
||||
<div className="space-y-2 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className={sectionHeaderStyles}>Examples</label>
|
||||
<label className="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Examples</label>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
|
|
@ -327,9 +358,23 @@ export function AgentConfig({
|
|||
)}
|
||||
</button>
|
||||
</div>
|
||||
{!isExamplesMaximized && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
💡 Tip: Use the maximized view for a better editing experience
|
||||
</div>
|
||||
)}
|
||||
{isExamplesMaximized ? (
|
||||
<div className="fixed inset-0 z-50 bg-white dark:bg-gray-900">
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Saved Banner for maximized examples */}
|
||||
{showSavedBanner && (
|
||||
<div className="absolute top-4 right-4 z-10 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 animate-in slide-in-from-top-2 duration-300">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Changes saved</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{agent.name}</span>
|
||||
|
|
@ -346,7 +391,8 @@ export function AgentConfig({
|
|||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden p-4">
|
||||
<EditableField
|
||||
<InputField
|
||||
type="text"
|
||||
key="examples-maximized"
|
||||
value={agent.examples || ""}
|
||||
onChange={(value) => {
|
||||
|
|
@ -354,21 +400,21 @@ export function AgentConfig({
|
|||
...agent,
|
||||
examples: value
|
||||
});
|
||||
showSavedMessage();
|
||||
}}
|
||||
placeholder="Enter examples for this agent"
|
||||
markdown
|
||||
multiline
|
||||
mentions
|
||||
mentionsAtValues={atMentions}
|
||||
showSaveButton={true}
|
||||
showDiscardButton={true}
|
||||
className="h-full min-h-0 overflow-auto !mb-0 !mt-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EditableField
|
||||
<InputField
|
||||
type="text"
|
||||
key="examples"
|
||||
value={agent.examples || ""}
|
||||
onChange={(value) => {
|
||||
|
|
@ -376,14 +422,13 @@ export function AgentConfig({
|
|||
...agent,
|
||||
examples: value
|
||||
});
|
||||
showSavedMessage();
|
||||
}}
|
||||
placeholder="Enter examples for this agent"
|
||||
markdown
|
||||
multiline
|
||||
mentions
|
||||
mentionsAtValues={atMentions}
|
||||
showSaveButton={true}
|
||||
showDiscardButton={true}
|
||||
className="h-full min-h-0 overflow-auto !mb-0 !mt-0"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -408,7 +453,8 @@ export function AgentConfig({
|
|||
<div className="flex flex-col md:flex-row md:items-start gap-1 md:gap-0">
|
||||
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300 md:w-32 mb-1 md:mb-0 md:pr-4">Name</label>
|
||||
<div className="flex-1">
|
||||
<EditableField
|
||||
<InputField
|
||||
type="text"
|
||||
value={localName}
|
||||
onChange={(value) => {
|
||||
setLocalName(value);
|
||||
|
|
@ -418,10 +464,8 @@ export function AgentConfig({
|
|||
name: value
|
||||
});
|
||||
}
|
||||
showSavedMessage();
|
||||
}}
|
||||
multiline={false}
|
||||
showSaveButton={true}
|
||||
showDiscardButton={true}
|
||||
error={nameError}
|
||||
className="w-full"
|
||||
/>
|
||||
|
|
@ -430,9 +474,13 @@ export function AgentConfig({
|
|||
<div className="flex flex-col md:flex-row md:items-start gap-1 md:gap-0">
|
||||
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300 md:w-32 mb-1 md:mb-0 md:pr-4">Description</label>
|
||||
<div className="flex-1">
|
||||
<EditableField
|
||||
<InputField
|
||||
type="text"
|
||||
value={agent.description || ""}
|
||||
onChange={(value) => handleUpdate({ ...agent, description: value })}
|
||||
onChange={(value: string) => {
|
||||
handleUpdate({ ...agent, description: value });
|
||||
showSavedMessage();
|
||||
}}
|
||||
multiline={true}
|
||||
placeholder="Enter a description for this agent"
|
||||
className="w-full"
|
||||
|
|
@ -458,10 +506,13 @@ export function AgentConfig({
|
|||
{ key: "user_facing", label: "Conversation Agent" },
|
||||
{ key: "internal", label: "Task Agent" }
|
||||
]}
|
||||
onChange={(value) => handleUpdate({
|
||||
...agent,
|
||||
outputVisibility: value as z.infer<typeof WorkflowAgent>["outputVisibility"]
|
||||
})}
|
||||
onChange={(value) => {
|
||||
handleUpdate({
|
||||
...agent,
|
||||
outputVisibility: value as z.infer<typeof WorkflowAgent>["outputVisibility"]
|
||||
});
|
||||
showSavedMessage();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -469,12 +520,16 @@ export function AgentConfig({
|
|||
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300 md:w-32 mb-1 md:mb-0 md:pr-4">Model</label>
|
||||
<div className="flex-1">
|
||||
{/* Model select/input logic unchanged */}
|
||||
{eligibleModels === "*" && <Input
|
||||
{eligibleModels === "*" && <InputField
|
||||
type="text"
|
||||
value={agent.model}
|
||||
onChange={(e) => handleUpdate({
|
||||
...agent,
|
||||
model: e.target.value as z.infer<typeof WorkflowAgent>["model"]
|
||||
})}
|
||||
onChange={(value: string) => {
|
||||
handleUpdate({
|
||||
...agent,
|
||||
model: value as z.infer<typeof WorkflowAgent>["model"]
|
||||
});
|
||||
showSavedMessage();
|
||||
}}
|
||||
className="w-full max-w-64"
|
||||
/>}
|
||||
{eligibleModels !== "*" && <Select
|
||||
|
|
@ -496,6 +551,7 @@ export function AgentConfig({
|
|||
...agent,
|
||||
model: key as z.infer<typeof WorkflowAgent>["model"]
|
||||
});
|
||||
showSavedMessage();
|
||||
}}
|
||||
>
|
||||
<SelectSection title="Available">
|
||||
|
|
@ -533,28 +589,31 @@ export function AgentConfig({
|
|||
<div className="flex flex-col md:flex-row md:items-start gap-1 md:gap-0">
|
||||
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300 md:w-32 mb-1 md:mb-0 md:pr-4">Max Calls From Parent</label>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
<InputField
|
||||
type="number"
|
||||
min="1"
|
||||
value={maxCallsInput}
|
||||
onChange={(e) => {
|
||||
setMaxCallsInput(e.target.value);
|
||||
onChange={(value: string) => {
|
||||
setMaxCallsInput(value);
|
||||
setMaxCallsError(null);
|
||||
}}
|
||||
onBlur={() => {
|
||||
const num = Number(maxCallsInput);
|
||||
if (!maxCallsInput || isNaN(num) || num < 1 || !Number.isInteger(num)) {
|
||||
setMaxCallsError("Must be an integer >= 1");
|
||||
return;
|
||||
}
|
||||
setMaxCallsError(null);
|
||||
if (num !== agent.maxCallsPerParentAgent) {
|
||||
handleUpdate({
|
||||
...agent,
|
||||
maxCallsPerParentAgent: num
|
||||
});
|
||||
const num = Number(value);
|
||||
if (value && !isNaN(num) && num >= 1 && Number.isInteger(num)) {
|
||||
if (num !== agent.maxCallsPerParentAgent) {
|
||||
handleUpdate({
|
||||
...agent,
|
||||
maxCallsPerParentAgent: num
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
validate={(value: string) => {
|
||||
const num = Number(value);
|
||||
if (!value || isNaN(num) || num < 1 || !Number.isInteger(num)) {
|
||||
return { valid: false, errorMessage: "Must be an integer >= 1" };
|
||||
}
|
||||
return { valid: true };
|
||||
}}
|
||||
error={maxCallsError}
|
||||
min={1}
|
||||
className="w-full max-w-24"
|
||||
/>
|
||||
{maxCallsError && (
|
||||
|
|
@ -581,10 +640,13 @@ export function AgentConfig({
|
|||
{ key: "relinquish_to_start", label: "Relinquish to 'start' agent" }
|
||||
]
|
||||
}
|
||||
onChange={(value) => handleUpdate({
|
||||
...agent,
|
||||
controlType: value as z.infer<typeof WorkflowAgent>["controlType"]
|
||||
})}
|
||||
onChange={(value) => {
|
||||
handleUpdate({
|
||||
...agent,
|
||||
controlType: value as z.infer<typeof WorkflowAgent>["controlType"]
|
||||
});
|
||||
showSavedMessage();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -615,6 +677,7 @@ export function AgentConfig({
|
|||
ragDataSources: [...(agent.ragDataSources || []), key]
|
||||
});
|
||||
}
|
||||
showSavedMessage();
|
||||
}}
|
||||
startContent={<PlusIcon className="w-4 h-4 text-gray-500" />}
|
||||
>
|
||||
|
|
@ -697,6 +760,7 @@ export function AgentConfig({
|
|||
...agent,
|
||||
ragDataSources: newSources
|
||||
});
|
||||
showSavedMessage();
|
||||
}}
|
||||
startContent={<Trash2 className="w-4 h-4" />}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,13 @@ export function PromptConfig({
|
|||
handleClose: () => void,
|
||||
}) {
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const [showSavedBanner, setShowSavedBanner] = useState(false);
|
||||
|
||||
// Function to show saved banner
|
||||
const showSavedMessage = () => {
|
||||
setShowSavedBanner(true);
|
||||
setTimeout(() => setShowSavedBanner(false), 2000);
|
||||
};
|
||||
|
||||
const atMentions = [
|
||||
...agents.map(a => ({ id: `agent:${a.name}`, value: `agent:${a.name}` })),
|
||||
|
|
@ -70,6 +77,15 @@ export function PromptConfig({
|
|||
}
|
||||
>
|
||||
<div className="flex flex-col gap-6 p-4">
|
||||
{/* Saved Banner */}
|
||||
{showSavedBanner && (
|
||||
<div className="absolute top-4 right-4 z-10 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 animate-in slide-in-from-top-2 duration-300">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Changes saved ✓</span>
|
||||
</div>
|
||||
)}
|
||||
{prompt.type === "base_prompt" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
|
|
@ -96,6 +112,7 @@ export function PromptConfig({
|
|||
...prompt,
|
||||
name: value
|
||||
});
|
||||
showSavedMessage();
|
||||
}}
|
||||
placeholder="Enter prompt name..."
|
||||
className="w-full text-sm bg-transparent focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-colors px-4 py-3"
|
||||
|
|
@ -120,6 +137,7 @@ export function PromptConfig({
|
|||
...prompt,
|
||||
prompt: e.target.value
|
||||
});
|
||||
showSavedMessage();
|
||||
}}
|
||||
placeholder="Edit prompt here..."
|
||||
className={`${textareaStyles} min-h-[200px]`}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import clsx from "clsx";
|
|||
import { SectionCard } from "@/components/common/section-card";
|
||||
import { ToolParamCard } from "@/components/common/tool-param-card";
|
||||
import { UserIcon, Settings, Settings2 } from "lucide-react";
|
||||
import { EditableField } from "@/app/lib/components/editable-field";
|
||||
import { InputField } from "@/app/lib/components/input-field";
|
||||
import Link from "next/link";
|
||||
import { Tooltip } from "@heroui/react";
|
||||
|
||||
|
|
@ -77,18 +77,13 @@ export function ParameterConfig({
|
|||
<label className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
Name
|
||||
</label>
|
||||
<Textarea
|
||||
<InputField
|
||||
type="text"
|
||||
value={localName}
|
||||
onChange={(e) => setLocalName(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (localName && localName !== param.name) {
|
||||
handleRename(param.name, localName);
|
||||
}
|
||||
}}
|
||||
onChange={(value: string) => setLocalName(value)}
|
||||
placeholder="Enter parameter name..."
|
||||
disabled={readOnly}
|
||||
className={textareaStyles}
|
||||
autoResize
|
||||
locked={readOnly}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -182,6 +177,18 @@ export function ToolConfig({
|
|||
const isReadOnly = tool.isMcp || tool.isComposio;
|
||||
const isWebhookTool = !tool.isMcp && !tool.isComposio;
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const [showSavedBanner, setShowSavedBanner] = useState(false);
|
||||
const [localToolName, setLocalToolName] = useState(tool.name);
|
||||
|
||||
// Function to show saved banner
|
||||
const showSavedMessage = () => {
|
||||
setShowSavedBanner(true);
|
||||
setTimeout(() => setShowSavedBanner(false), 2000);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLocalToolName(tool.name);
|
||||
}, [tool.name]);
|
||||
|
||||
// Log when parameters are being rendered
|
||||
useEffect(() => {
|
||||
|
|
@ -215,6 +222,7 @@ export function ToolConfig({
|
|||
...tool,
|
||||
parameters: { ...tool.parameters!, properties: newProperties, required: newRequired }
|
||||
});
|
||||
showSavedMessage();
|
||||
}
|
||||
|
||||
function handleParamUpdate(name: string, data: {
|
||||
|
|
@ -243,6 +251,7 @@ export function ToolConfig({
|
|||
required: newRequired,
|
||||
}
|
||||
});
|
||||
showSavedMessage();
|
||||
}
|
||||
|
||||
function handleParamDelete(paramName: string) {
|
||||
|
|
@ -260,16 +269,20 @@ export function ToolConfig({
|
|||
required: newRequired,
|
||||
}
|
||||
});
|
||||
showSavedMessage();
|
||||
}
|
||||
|
||||
function validateToolName(value: string) {
|
||||
if (value.length === 0) {
|
||||
return "Name cannot be empty";
|
||||
setNameError("Name cannot be empty");
|
||||
return false;
|
||||
}
|
||||
if (value !== tool.name && usedToolNames.has(value)) {
|
||||
return "This name is already taken";
|
||||
setNameError("This name is already taken");
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
setNameError(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Log parameter rendering in the actual parameter section
|
||||
|
|
@ -343,6 +356,15 @@ export function ToolConfig({
|
|||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4 pb-4 pt-4 p-4">
|
||||
{/* Saved Banner */}
|
||||
{showSavedBanner && (
|
||||
<div className="absolute top-4 right-4 z-10 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 animate-in slide-in-from-top-2 duration-300">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Changes saved ✓</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Tool Type Section */}
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/30 dark:to-indigo-950/30 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
|
|
@ -405,25 +427,22 @@ export function ToolConfig({
|
|||
<div className="flex flex-col md:flex-row md:items-start gap-1 md:gap-0">
|
||||
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300 md:w-32 mb-1 md:mb-0 md:pr-4">Name</label>
|
||||
<div className="flex-1">
|
||||
<EditableField
|
||||
value={tool.name}
|
||||
<InputField
|
||||
type="text"
|
||||
value={localToolName}
|
||||
locked={isReadOnly}
|
||||
onChange={(value) => {
|
||||
setNameError(validateToolName(value));
|
||||
if (!validateToolName(value)) {
|
||||
onChange={(value: string) => {
|
||||
setLocalToolName(value);
|
||||
if (validateToolName(value)) {
|
||||
handleUpdate({
|
||||
...tool,
|
||||
name: value
|
||||
});
|
||||
}
|
||||
showSavedMessage();
|
||||
}}
|
||||
validate={(value) => {
|
||||
const error = validateToolName(value);
|
||||
setNameError(error);
|
||||
return { valid: !error, errorMessage: error || undefined };
|
||||
}}
|
||||
showSaveButton={true}
|
||||
showDiscardButton={true}
|
||||
|
||||
|
||||
error={nameError}
|
||||
className="w-full"
|
||||
/>
|
||||
|
|
@ -432,10 +451,14 @@ export function ToolConfig({
|
|||
<div className="flex flex-col md:flex-row md:items-start gap-1 md:gap-0">
|
||||
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300 md:w-32 mb-1 md:mb-0 md:pr-4">Description</label>
|
||||
<div className="flex-1">
|
||||
<EditableField
|
||||
<InputField
|
||||
type="text"
|
||||
locked={isReadOnly}
|
||||
value={tool.description || ""}
|
||||
onChange={(value) => handleUpdate({ ...tool, description: value })}
|
||||
onChange={(value: string) => {
|
||||
handleUpdate({ ...tool, description: value });
|
||||
showSavedMessage();
|
||||
}}
|
||||
multiline={true}
|
||||
placeholder="Describe what this tool does..."
|
||||
className="w-full"
|
||||
|
|
@ -458,10 +481,13 @@ export function ToolConfig({
|
|||
<div className="flex items-center gap-2 mb-1">
|
||||
<Switch
|
||||
isSelected={tool.mockTool}
|
||||
onValueChange={(value) => handleUpdate({
|
||||
...tool,
|
||||
mockTool: value,
|
||||
})}
|
||||
onValueChange={(value) => {
|
||||
handleUpdate({
|
||||
...tool,
|
||||
mockTool: value,
|
||||
});
|
||||
showSavedMessage();
|
||||
}}
|
||||
size="sm"
|
||||
color="primary"
|
||||
/>
|
||||
|
|
@ -474,12 +500,16 @@ export function ToolConfig({
|
|||
<div className="flex flex-col gap-1 mt-4">
|
||||
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300 mb-1">Mock Response Instructions</label>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 mb-1">Describe the response the mock tool should return. This will be shown in the chat when the tool is called.</span>
|
||||
<EditableField
|
||||
<InputField
|
||||
type="text"
|
||||
value={tool.mockInstructions || ''}
|
||||
onChange={(value) => handleUpdate({
|
||||
...tool,
|
||||
mockInstructions: value
|
||||
})}
|
||||
onChange={(value: string) => {
|
||||
handleUpdate({
|
||||
...tool,
|
||||
mockInstructions: value
|
||||
});
|
||||
showSavedMessage();
|
||||
}}
|
||||
multiline={true}
|
||||
placeholder="Mock response instructions..."
|
||||
className="w-full text-xs p-2 bg-white dark:bg-gray-900"
|
||||
|
|
@ -533,6 +563,7 @@ export function ToolConfig({
|
|||
required: [...(tool.parameters?.required || []), newParamName]
|
||||
}
|
||||
});
|
||||
showSavedMessage();
|
||||
}}
|
||||
className="hover:bg-indigo-100 dark:hover:bg-indigo-900 hover:shadow-indigo-500/20 dark:hover:shadow-indigo-400/20 hover:shadow-lg transition-all mt-2"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { App as ChatApp } from "../playground/app";
|
|||
import { z } from "zod";
|
||||
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Spinner, Tooltip, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from "@heroui/react";
|
||||
import { PromptConfig } from "../entities/prompt_config";
|
||||
import { EditableField } from "../../../lib/components/editable-field";
|
||||
import { InputField } from "../../../lib/components/input-field";
|
||||
import { RelativeTime } from "@primer/react";
|
||||
import { USE_PRODUCT_TOUR } from "@/app/lib/feature_flags";
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||
import { Select, SelectItem } from "@heroui/react";
|
||||
import { Checkbox } from "@heroui/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EditableField } from "@/app/lib/components/editable-field";
|
||||
import { InputField } from "@/app/lib/components/input-field";
|
||||
|
||||
export function ToolParamCard({
|
||||
param,
|
||||
|
|
@ -71,7 +71,7 @@ export function ToolParamCard({
|
|||
<div className="flex flex-col md:flex-row md:items-start gap-1 md:gap-0">
|
||||
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300 md:w-32 mb-1 md:mb-0 md:pr-4">Name</label>
|
||||
<div className="flex-1">
|
||||
<EditableField
|
||||
<InputField type="text"
|
||||
value={localName}
|
||||
onChange={(value: string) => {
|
||||
setLocalName(value);
|
||||
|
|
@ -80,8 +80,7 @@ export function ToolParamCard({
|
|||
}
|
||||
}}
|
||||
multiline={false}
|
||||
showSaveButton={true}
|
||||
showDiscardButton={true}
|
||||
|
||||
className="w-full"
|
||||
locked={readOnly}
|
||||
/>
|
||||
|
|
@ -90,7 +89,8 @@ export function ToolParamCard({
|
|||
<div className="flex flex-col md:flex-row md:items-start gap-1 md:gap-0">
|
||||
<label className="text-sm font-semibold text-gray-600 dark:text-gray-300 md:w-32 mb-1 md:mb-0 md:pr-4">Description</label>
|
||||
<div className="flex-1">
|
||||
<EditableField
|
||||
<InputField
|
||||
type="text"
|
||||
value={param.description}
|
||||
onChange={(value: string) => handleUpdate(param.name, {
|
||||
...param,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue