Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 128 additions & 1 deletion agentex-ui/components/primary-content/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { DataContent, TextContent } from 'agentex/resources';
import { ArrowUp } from 'lucide-react';

import { useAgentexClient } from '@/components/providers';
import { Button } from '@/components/ui/button';
import { IconButton } from '@/components/ui/icon-button';
import { Switch } from '@/components/ui/switch';
import { toast } from '@/components/ui/toast';
Expand All @@ -21,6 +22,7 @@ import {
} from '@/hooks/use-safe-search-params';
import { useSendMessage } from '@/hooks/use-task-messages';
import { useTask } from '@/hooks/use-tasks';
import { parseOptionalJsonObject } from '@/lib/json-utils';
import { TaskStatusEnum } from '@/lib/types';

type PromptInputProps = {
Expand Down Expand Up @@ -49,6 +51,8 @@ export function PromptInput({ prompt, setPrompt }: PromptInputProps) {
const { taskID, agentName, updateParams } = useSafeSearchParams();
const [isClient, setIsClient] = useState(false);
const [isSendingJSON, setIsSendingJSON] = useState(false);
const [isTaskParamsEnabled, setIsTaskParamsEnabled] = useState(false);
const [taskParamsPrompt, setTaskParamsPrompt] = useState('');

const { agentexClient } = useAgentexClient();

Expand All @@ -58,6 +62,7 @@ export function PromptInput({ prompt, setPrompt }: PromptInputProps) {

const textInputRef = useRef<HTMLInputElement>(null);
const codeMirrorViewRef = useRef<EditorView | null>(null);
const taskParamsCodeMirrorViewRef = useRef<EditorView | null>(null);

const isTaskTerminal = useMemo(() => {
if (!taskID || !task) return false;
Expand Down Expand Up @@ -116,18 +121,34 @@ export function PromptInput({ prompt, setPrompt }: PromptInputProps) {
}
}

let taskParams: Record<string, unknown> | undefined;
if (!currentTaskId && isTaskParamsEnabled) {
try {
taskParams = parseOptionalJsonObject(taskParamsPrompt);
} catch (error) {
toast.error({
title: 'Invalid task creation params',
message:
error instanceof Error ? error.message : 'Invalid JSON object',
});
return;
}
}

setPrompt('');

if (!currentTaskId) {
const task = await createTaskMutation.mutateAsync({
agentName: agentName,
params: {
params: taskParams ?? {
description: prompt,
content: currentPrompt,
},
});
currentTaskId = task.id;
updateParams({ [SearchParamKey.TASK_ID]: currentTaskId });
setIsTaskParamsEnabled(false);
setTaskParamsPrompt('');
}

const content: TextContent | DataContent = isSendingJSON
Expand Down Expand Up @@ -159,10 +180,22 @@ export function PromptInput({ prompt, setPrompt }: PromptInputProps) {
sendMessageMutation,
setPrompt,
isSendingJSON,
isTaskParamsEnabled,
taskParamsPrompt,
]);

return (
<div className="flex w-full max-w-3xl flex-col gap-2">
{!taskID && (
<TaskCreationParamsEditor
value={taskParamsPrompt}
setValue={setTaskParamsPrompt}
isEnabled={isTaskParamsEnabled}
setIsEnabled={setIsTaskParamsEnabled}
isDisabled={isDisabled}
codeMirrorViewRef={taskParamsCodeMirrorViewRef}
/>
)}
<div
className={`border-input dark:bg-input ${isDisabled ? 'bg-muted scale-90 cursor-not-allowed' : 'scale-100'} flex w-full items-center justify-between rounded-4xl border py-2 pr-2 pl-6 shadow-sm transition-transform duration-300 disabled:cursor-not-allowed`}
>
Expand Down Expand Up @@ -211,6 +244,100 @@ export function PromptInput({ prompt, setPrompt }: PromptInputProps) {
);
}

const TaskCreationParamsEditor = ({
value,
setValue,
isEnabled,
setIsEnabled,
isDisabled,
codeMirrorViewRef,
}: {
value: string;
setValue: (value: string) => void;
isEnabled: boolean;
setIsEnabled: (isEnabled: boolean) => void;
isDisabled: boolean;
codeMirrorViewRef: React.MutableRefObject<EditorView | null>;
}) => {
const handleAdd = useCallback(() => {
setIsEnabled(true);
}, [setIsEnabled]);

const handleRemove = useCallback(() => {
setIsEnabled(false);
setValue('');
}, [setIsEnabled, setValue]);

useEffect(() => {
if (!isEnabled) return;

requestAnimationFrame(() => {
codeMirrorViewRef.current?.focus();
});
}, [codeMirrorViewRef, isEnabled]);

return (
<div
className="text-muted-foreground flex flex-col gap-2 px-4 text-sm"
style={{
opacity: isDisabled ? 0 : 1,
transition: 'opacity 0.2s',
}}
>
{isEnabled ? (
<div className="border-input bg-background dark:bg-input/30 rounded-xl border p-2 shadow-sm">
<div className="mb-2 flex items-center justify-between px-1">
<span className="text-xs font-medium">Task creation params</span>
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground h-auto px-0 py-0 text-xs"
onClick={handleRemove}
disabled={isDisabled}
>
Remove
</Button>
</div>
<CodeMirror
className="text-sm"
value={value}
onChange={(nextValue: string) => setValue(nextValue)}
onCreateEditor={view => {
codeMirrorViewRef.current = view;
}}
extensions={[json(), noOutlineTheme, closeBrackets()]}
placeholder='{ "container_id": "..." }'
basicSetup={{
lineNumbers: false,
foldGutter: false,
highlightActiveLineGutter: false,
highlightActiveLine: false,
}}
editable={!isDisabled}
theme="none"
maxHeight="180px"
/>
<p className="text-muted-foreground mt-2 px-1 text-xs">
Optional JSON object sent only when this GUI starts a new task.
</p>
</div>
) : (
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground h-auto w-fit px-0 py-0 text-xs"
onClick={handleAdd}
disabled={isDisabled}
>
+ Add task creation params
</Button>
)}
</div>
);
};

const TextInput = ({
prompt,
setPrompt,
Expand Down
33 changes: 32 additions & 1 deletion agentex-ui/lib/json-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';

import { serializeValue } from '@/lib/json-utils';
import { parseOptionalJsonObject, serializeValue } from '@/lib/json-utils';
import type { JsonValue } from '@/lib/types';

describe('serializeValue', () => {
Expand Down Expand Up @@ -77,3 +77,34 @@ describe('serializeValue', () => {
expect(result).toBe('0');
});
});

describe('parseOptionalJsonObject', () => {
it('returns undefined for empty input', () => {
expect(parseOptionalJsonObject('')).toBeUndefined();
expect(parseOptionalJsonObject(' ')).toBeUndefined();
});

it('parses a valid JSON object', () => {
expect(parseOptionalJsonObject('{ "container_id": "abc123" }')).toEqual({
container_id: 'abc123',
});
});

it('rejects invalid JSON', () => {
expect(() => parseOptionalJsonObject('{ bad json }')).toThrow(
'Invalid JSON'
);
});

it('rejects arrays', () => {
expect(() => parseOptionalJsonObject('[]')).toThrow(
'Expected a JSON object'
);
});

it('rejects null', () => {
expect(() => parseOptionalJsonObject('null')).toThrow(
'Expected a JSON object'
);
});
});
29 changes: 29 additions & 0 deletions agentex-ui/lib/json-utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { JsonValue } from '@/lib/types';

export type JsonObject = { [key: string]: JsonValue };

export function serializeValue(data: JsonValue): string {
if (typeof data === 'object' && data !== null) {
return JSON.stringify(data, null, 2);
Expand All @@ -9,3 +11,30 @@ export function serializeValue(data: JsonValue): string {
}
return String(data);
}

export function parseJsonObject(input: string): JsonObject {
let parsedValue: unknown;
try {
parsedValue = JSON.parse(input) as unknown;
} catch (error) {
throw new Error('Invalid JSON', { cause: error });
}

if (
parsedValue === null ||
typeof parsedValue !== 'object' ||
Array.isArray(parsedValue)
) {
throw new Error('Expected a JSON object');
}

return parsedValue as JsonObject;
}

export function parseOptionalJsonObject(input: string): JsonObject | undefined {
if (!input.trim()) {
return undefined;
}

return parseJsonObject(input);
}
Loading