Merge pull request #60 from rowboatlabs/dev

Dev
This commit is contained in:
Ramnique Singh 2025-04-04 22:35:23 +05:30 committed by GitHub
commit 57693b36cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 406 additions and 179 deletions

View file

@ -4,12 +4,12 @@ import { useRef, useState, createContext, useContext, useCallback, forwardRef, u
import { CopilotChatContext } from "../../../lib/types/copilot_types";
import { CopilotMessage } from "../../../lib/types/copilot_types";
import { CopilotAssistantMessageActionPart } from "../../../lib/types/copilot_types";
import { Workflow } from "../../../lib/types/workflow_types";
import { Workflow } from "@/app/lib/types/workflow_types";
import { z } from "zod";
import { getCopilotResponse } from "@/app/actions/copilot_actions";
import { Action as WorkflowDispatch } from "../workflow/workflow_editor";
import { Panel } from "@/components/common/panel-common";
import { ComposeBox } from "@/components/common/compose-box";
import { ComposeBoxCopilot } from "@/components/common/compose-box-copilot";
import { Messages } from "./components/messages";
import { CopyIcon, CheckIcon, PlusIcon, XIcon } from "lucide-react";
@ -23,19 +23,21 @@ export function getAppliedChangeKey(messageIndex: number, actionIndex: number, f
return `${messageIndex}-${actionIndex}-${field}`;
}
const App = forwardRef(function App({
interface AppProps {
projectId: string;
workflow: z.infer<typeof Workflow>;
dispatch: (action: any) => void;
chatContext?: any;
onCopyJson?: (data: { messages: any[], lastRequest: any, lastResponse: any }) => void;
}
const App = forwardRef<{ handleCopyChat: () => void }, AppProps>(function App({
projectId,
workflow,
dispatch,
chatContext = undefined,
onCopyJson,
}: {
projectId: string;
workflow: z.infer<typeof Workflow>;
dispatch: (action: WorkflowDispatch) => void;
chatContext?: z.infer<typeof CopilotChatContext>;
onCopyJson: (data: { messages: any[], lastRequest: any, lastResponse: any }) => void;
}, ref: Ref<{ handleCopyChat: () => void }>) {
}, ref) {
const messagesEndRef = useRef<HTMLDivElement>(null);
const [messages, setMessages] = useState<z.infer<typeof CopilotMessage>[]>([]);
const [loadingResponse, setLoadingResponse] = useState(false);
@ -55,7 +57,7 @@ const App = forwardRef(function App({
content: prompt
}]);
}
}, [projectId, messages.length, setMessages]);
}, [projectId, messages.length]);
// Reset discardContext when chatContext changes
useEffect(() => {
@ -66,14 +68,11 @@ const App = forwardRef(function App({
const effectiveContext = discardContext ? null : chatContext;
function handleUserMessage(prompt: string) {
setMessages([...messages, {
setMessages(currentMessages => [...currentMessages, {
role: 'user',
content: prompt
}]);
setResponseError(null);
// Set loading immediately after adding user message
// This ensures ComposeBox clears and disables right away
setLoadingResponse(true);
}
const handleApplyChange = useCallback((
@ -176,30 +175,31 @@ const App = forwardRef(function App({
}
}, [dispatch, appliedChanges, messages]);
// Second useEffect for copilot response
// Effect for handling copilot responses
useEffect(() => {
let ignore = false;
async function process() {
if (!messages.length) return;
const lastMessage = messages[messages.length - 1];
if (lastMessage.role !== 'user') return;
setLoadingResponse(true);
setResponseError(null);
try {
setLastRequest(null);
setLastResponse(null);
const response = await getCopilotResponse(
projectId,
messages,
workflow,
effectiveContext || null,
);
if (ignore) {
return;
}
if (ignore) return;
setLastRequest(response.rawRequest);
setLastResponse(response.rawResponse);
setMessages([...messages, response.message]);
setMessages(currentMessages => [...currentMessages, response.message]);
} catch (err) {
if (!ignore) {
setResponseError(`Failed to get copilot response: ${err instanceof Error ? err.message : 'Unknown error'}`);
@ -211,43 +211,26 @@ const App = forwardRef(function App({
}
}
// if no messages, return
if (messages.length === 0) {
return;
}
// if last message is not from role user
// or tool, return
const last = messages[messages.length - 1];
if (responseError) {
return;
}
if (last.role !== 'user') {
return;
}
process();
return () => {
ignore = true;
};
}, [
messages,
projectId,
responseError,
workflow,
effectiveContext,
setLoadingResponse,
setMessages,
setResponseError
]);
}, [messages, projectId, workflow, effectiveContext]);
// Scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, loadingResponse]);
const handleCopyChat = useCallback(() => {
onCopyJson({
messages,
lastRequest,
lastResponse,
});
if (onCopyJson) {
onCopyJson({
messages,
lastRequest,
lastResponse,
});
}
}, [messages, lastRequest, lastResponse, onCopyJson]);
useImperativeHandle(ref, () => ({
@ -295,9 +278,9 @@ const App = forwardRef(function App({
</button>
</div>
</div>}
<ComposeBox
<ComposeBoxCopilot
handleUserMessage={handleUserMessage}
messages={messages as any[]}
messages={messages}
loading={loadingResponse}
disabled={loadingResponse}
/>
@ -386,3 +369,4 @@ export function Copilot({
</Panel>
);
}

View file

@ -71,8 +71,8 @@ function AssistantMessage({
return (
<div className="w-full">
<div className="px-4 py-2.5 text-sm leading-relaxed text-gray-700 dark:text-gray-200">
<div className="flex flex-col gap-2">
<div className="text-left">
<div className="flex flex-col gap-4">
<div className="text-left flex flex-col gap-4">
{content.response.map((part, actionIndex) => {
if (part.type === "text") {
return <MarkdownContent key={actionIndex} content={part.content} />;
@ -152,17 +152,19 @@ export function Messages({
return (
<div className="h-full">
<div className="flex flex-col space-y-4 px-4 pt-4">
<div className="flex flex-col space-y-4">
{messages.map((message, index) => (
<div key={index}>
{renderMessage(message, index)}
</div>
))}
{loadingResponse && <AssistantMessageLoading />}
</div>
<div ref={messagesEndRef} />
<div className="flex flex-col [&>*]:mb-4">
{messages.map((message, index) => (
<div key={index} className="mb-4">
{renderMessage(message, index)}
</div>
))}
{loadingResponse && (
<div className="animate-pulse">
<AssistantMessageLoading />
</div>
)}
</div>
<div ref={messagesEndRef} />
</div>
);
}

View file

@ -526,7 +526,7 @@ function reducer(state: State, action: Action): State {
const existingToolIndex = draft.workflow.tools.findIndex(
tool => tool.name === newTool.name
);
if (existingToolIndex !== -1) {
// Replace existing tool
draft.workflow.tools[existingToolIndex] = newTool;
@ -803,9 +803,7 @@ export function WorkflowEditor({
View versions
</DropdownItem>
</DropdownSection>
<div className="border-t border-gray-200 dark:border-gray-700" />
<DropdownSection>
<DropdownItem
key="clone"
@ -814,7 +812,7 @@ export function WorkflowEditor({
>
Clone this version
</DropdownItem>
<DropdownItem
key="publish"
startContent={<div className="text-indigo-500"><RadioIcon size={16} /></div>}
@ -823,9 +821,7 @@ export function WorkflowEditor({
Make version live
</DropdownItem>
</DropdownSection>
<div className="border-t border-gray-200 dark:border-gray-700" />
<DropdownSection>
<DropdownItem
key="clipboard"

View file

@ -7,15 +7,27 @@ import { listProjects, createProject, createProjectFromPrompt } from "../../acti
import { useRouter } from 'next/navigation';
import { tokens } from "@/app/styles/design-tokens";
import clsx from 'clsx';
import { templates } from "@/app/lib/project_templates";
import { templates, starting_copilot_prompts } from "@/app/lib/project_templates";
import { SectionHeading } from "@/components/ui/section-heading";
import { Textarea } from "@/components/ui/textarea";
import { TemplateCardsList } from "./components/template-cards-list";
import { SearchProjects } from "./components/search-projects";
import { CustomPromptCard } from "./components/custom-prompt-card";
import { Submit } from "./components/submit-button";
import { PageHeading } from "@/components/ui/page-heading";
import { ChevronDown, ChevronUp } from "lucide-react";
const sectionHeaderStyles = clsx(
"text-sm font-medium",
"text-gray-900 dark:text-gray-100"
);
const textareaStyles = clsx(
"w-full",
"rounded-lg p-3",
"border border-gray-200 dark:border-gray-700",
"bg-white dark:bg-gray-800",
"hover:bg-gray-50 dark:hover:bg-gray-750",
"focus:shadow-inner focus:ring-2 focus:ring-indigo-500/20 dark:focus:ring-indigo-400/20",
"placeholder:text-gray-400 dark:placeholder:text-gray-500"
);
export default function App() {
const [projects, setProjects] = useState<z.infer<typeof Project>[]>([]);
@ -24,15 +36,19 @@ export default function App() {
const [selectedCard, setSelectedCard] = useState<'custom' | any>('custom');
const [customPrompt, setCustomPrompt] = useState("Create a customer support assistant with one example agent");
const [name, setName] = useState("");
const [defaultName, setDefaultName] = useState('Untitled 1');
const [defaultName, setDefaultName] = useState('Assistant 1');
const [isExamplesExpanded, setIsExamplesExpanded] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<string>('blank');
const [showCustomPrompt, setShowCustomPrompt] = useState(false);
const [promptError, setPromptError] = useState<string | null>(null);
const [hasEditedPrompt, setHasEditedPrompt] = useState(false);
const getNextUntitledNumber = (projects: z.infer<typeof Project>[]) => {
const getNextAssistantNumber = (projects: z.infer<typeof Project>[]) => {
const untitledProjects = projects
.map(p => p.name)
.filter(name => name.startsWith('Untitled '))
.filter(name => name.startsWith('Assistant '))
.map(name => {
const num = parseInt(name.replace('Untitled ', ''));
const num = parseInt(name.replace('Assistant ', ''));
return isNaN(num) ? 0 : num;
});
@ -54,8 +70,8 @@ export default function App() {
setProjects(sortedProjects);
setIsLoading(false);
const nextNumber = getNextUntitledNumber(sortedProjects);
const newDefaultName = `Untitled ${nextNumber}`;
const nextNumber = getNextAssistantNumber(sortedProjects);
const newDefaultName = `Assistant ${nextNumber}`;
setDefaultName(newDefaultName);
setName(newDefaultName);
}
@ -80,44 +96,66 @@ export default function App() {
const router = useRouter();
async function handleSubmit(formData: FormData) {
// Check if it's a template (from templates object) or a copilot prompt
const isTemplate = selectedCard?.id && selectedCard.id in templates;
if (selectedCard === 'custom' || !isTemplate) {
// Handle custom prompt or copilot starting prompts
console.log('Creating project from prompt');
try {
const newFormData = new FormData();
newFormData.append('name', name);
newFormData.append('prompt', selectedCard === 'custom' ? customPrompt : selectedCard.prompt);
const response = await createProjectFromPrompt(newFormData);
if (!response?.id) {
throw new Error('Project creation failed');
}
// Store prompt in local storage
const promptToStore = selectedCard === 'custom' ? customPrompt : selectedCard.prompt;
if (promptToStore) {
localStorage.setItem(`project_prompt_${response.id}`, promptToStore);
}
router.push(`/projects/${response.id}/workflow`);
} catch (error) {
console.error('Error creating project:', error);
}
const handleTemplateChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
setSelectedTemplate(value);
if (value === 'blank') {
setShowCustomPrompt(false);
setCustomPrompt('');
} else if (value === 'custom') {
setShowCustomPrompt(true);
setCustomPrompt('');
} else {
// Handle regular template
console.log('Creating template project');
try {
// Handle example prompts
const prompt = starting_copilot_prompts[value];
if (prompt) {
setShowCustomPrompt(true);
setCustomPrompt(prompt);
}
}
};
const validatePrompt = (value: string) => {
if (!value.trim()) {
return { valid: false, errorMessage: "Prompt cannot be empty" };
}
return { valid: true };
};
async function handleSubmit(formData: FormData) {
try {
// Validate prompt if custom prompt section is shown
if (showCustomPrompt && !customPrompt.trim()) {
setPromptError("Prompt cannot be empty");
return;
}
let response;
if (selectedTemplate === 'blank') {
const newFormData = new FormData();
newFormData.append('name', name);
newFormData.append('template', selectedCard.id);
return await createProject(newFormData);
} catch (error) {
console.error('Error creating project:', error);
newFormData.append('template', 'default');
response = await createProject(newFormData);
} else {
const newFormData = new FormData();
newFormData.append('name', name);
newFormData.append('prompt', customPrompt);
response = await createProjectFromPrompt(newFormData);
if (response?.id && customPrompt) {
localStorage.setItem(`project_prompt_${response.id}`, customPrompt);
}
}
if (!response?.id) {
throw new Error('Project creation failed');
}
router.push(`/projects/${response.id}/workflow`);
} catch (error) {
console.error('Error creating project:', error);
}
}
@ -159,75 +197,124 @@ export default function App() {
{/* Right side: Project Creation */}
<div className="overflow-auto">
<section className="card h-full">
<div className="px-4 pt-4 flex justify-between items-start">
<div>
<SectionHeading
subheading="Set up a new AI assistant"
>
Create a new project
</SectionHeading>
</div>
<div className="pt-1">
<Submit />
</div>
<div className="px-4 pt-4">
<SectionHeading subheading="Set up a new AI assistant">
Create a new project
</SectionHeading>
</div>
<form
id="create-project-form"
action={handleSubmit}
onKeyDown={handleKeyDown}
className="px-4 pt-4 pb-8 space-y-6"
className="px-4 pt-4 pb-8 space-y-8"
>
<div className="space-y-3">
<SectionHeading>Name your assistant</SectionHeading>
<Textarea
required
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="min-h-[60px] px-4 py-3"
placeholder={defaultName}
/>
</div>
<input type="hidden" name="template" value={selectedCard} />
<div className="space-y-6">
<div className="space-y-3">
<SectionHeading>Start with your own prompt</SectionHeading>
<CustomPromptCard
selected={selectedCard === 'custom'}
onSelect={() => handleCardSelect('custom')}
customPrompt={customPrompt}
onCustomPromptChange={setCustomPrompt}
{/* Name Section */}
<div className="space-y-4">
<div className="flex flex-col gap-2">
<label className={sectionHeaderStyles}>
Name
</label>
<Textarea
required
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
className={clsx(
textareaStyles,
"min-h-[60px]",
"text-base",
"text-gray-900 dark:text-gray-100"
)}
placeholder={defaultName}
/>
</div>
<div className="space-y-3">
<button
type="button"
onClick={() => setIsExamplesExpanded(!isExamplesExpanded)}
className="flex items-center gap-2 w-full"
>
<div className="flex-1 text-left">
<SectionHeading>
Or choose an example
</SectionHeading>
</div>
{isExamplesExpanded ? (
<ChevronUp className="w-5 h-5 text-gray-500" />
) : (
<ChevronDown className="w-5 h-5 text-gray-500" />
</div>
{/* Template Selection Section */}
<div className="space-y-4">
<div className="flex flex-col gap-2">
<label className={sectionHeaderStyles}>
Choose how to start
</label>
<select
value={selectedTemplate}
onChange={handleTemplateChange}
className={clsx(
"w-[400px]",
"px-4 py-2",
"pr-8",
"rounded-lg",
"border border-gray-200 dark:border-gray-700",
"bg-white dark:bg-gray-800",
"hover:bg-gray-50 dark:hover:bg-gray-750",
"focus:shadow-inner focus:ring-2 focus:ring-indigo-500/20 dark:focus:ring-indigo-400/20",
"appearance-none",
"text-base",
"text-gray-900 dark:text-gray-100",
"bg-[url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22currentColor%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E')]",
"bg-[length:1.25em]",
"bg-[calc(100%-8px)_center]",
"bg-no-repeat",
"dark:bg-[url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23ffffff%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E')]"
)}
</button>
{isExamplesExpanded && (
<TemplateCardsList
selectedCard={selectedCard}
onSelectCard={handleCardSelect}
/>
)}
>
<option value="blank">Start with a blank template</option>
<option value="custom">Write your own starting prompt</option>
<optgroup label="Example Prompts">
{starting_copilot_prompts &&
Object.entries(starting_copilot_prompts)
.filter(([name]) => name !== 'Blank Template')
.map(([name, prompt]) => (
<option key={name} value={name}>
{name}
</option>
))
}
</optgroup>
</select>
</div>
</div>
{/* Custom Prompt Section - Only show when needed */}
{showCustomPrompt && (
<div className="space-y-4">
<div className="flex flex-col gap-2">
<label className={sectionHeaderStyles}>
{selectedTemplate === 'custom' ? 'Write your prompt' : 'Customize the prompt'}
</label>
<div className="space-y-2">
<Textarea
value={customPrompt}
onChange={(e) => {
setCustomPrompt(e.target.value);
setPromptError(null);
}}
placeholder="Example: Create a customer support assistant that can handle product inquiries and returns"
className={clsx(
textareaStyles,
"min-h-[100px]",
"text-base",
"text-gray-900 dark:text-gray-100",
promptError && "border-red-500 focus:ring-red-500/20"
)}
autoResize
required
/>
{promptError && (
<p className="text-sm text-red-500">
{promptError}
</p>
)}
</div>
</div>
</div>
)}
{/* Submit Button */}
<div className="pt-6 w-full">
<Submit />
</div>
</form>
</section>
</div>

View file

@ -9,13 +9,15 @@ interface CustomPromptCardProps {
onSelect: () => void;
customPrompt: string;
onCustomPromptChange: (value: string) => void;
placeholder?: string;
}
export function CustomPromptCard({
selected,
onSelect,
customPrompt,
onCustomPromptChange
onCustomPromptChange,
placeholder
}: CustomPromptCardProps) {
const DEFAULT_PROMPT = "Create a customer support assistant with one example agent";
@ -55,7 +57,7 @@ export function CustomPromptCard({
tokens.colors.light.text.primary,
tokens.colors.dark.text.primary
)}>
Custom Prompt
Prompt
</h3>
<div
onClick={(e) => e.stopPropagation()}
@ -65,6 +67,7 @@ export function CustomPromptCard({
<Textarea
value={customPrompt}
onChange={(e) => onCustomPromptChange(e.target.value)}
placeholder={placeholder}
className={clsx(
"w-full min-h-[100px]",
"resize-none",

View file

@ -0,0 +1,147 @@
'use client';
import { Button, Spinner } from "@heroui/react";
import { useRef, useState, useEffect } from "react";
import { Textarea } from "@/components/ui/textarea";
// Add a type to support both message formats
type FlexibleMessage = {
role: 'user' | 'assistant' | 'system' | 'tool';
content: string | any;
version?: string;
chatId?: string;
createdAt?: string;
// Add any other optional fields that might be needed
};
export function ComposeBoxCopilot({
minRows=3,
disabled=false,
loading=false,
handleUserMessage,
messages,
}: {
minRows?: number;
disabled?: boolean;
loading?: boolean;
handleUserMessage: (prompt: string) => void;
messages: FlexibleMessage[]; // Use the flexible message type
}) {
const [input, setInput] = useState('');
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
function handleInput() {
const prompt = input.trim();
if (!prompt) {
return;
}
setInput('');
handleUserMessage(prompt);
}
function handleInputKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleInput();
}
}
// focus on the input field only when there is at least one message
useEffect(() => {
if (messages.length > 0) {
inputRef.current?.focus();
}
}, [messages]);
return (
<div className="relative group">
{/* Keyboard shortcut hint */}
<div className="absolute -top-6 right-0 text-xs text-gray-500 dark:text-gray-400 opacity-0
group-hover:opacity-100 transition-opacity">
Press + Enter to send
</div>
{/* Outer container with padding */}
<div className="rounded-2xl border-[1.5px] border-gray-200 dark:border-[#2a2d31] p-3 relative
bg-white dark:bg-[#1e2023] flex items-end gap-2">
{/* Textarea */}
<div className="flex-1">
<Textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleInputKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
disabled={disabled || loading}
placeholder="Type a message..."
autoResize={true}
maxHeight={120}
className={`
!min-h-0
!border-0 !shadow-none !ring-0
bg-transparent
resize-none
overflow-y-auto
[&::-webkit-scrollbar]:w-1
[&::-webkit-scrollbar-track]:bg-transparent
[&::-webkit-scrollbar-thumb]:bg-gray-300
[&::-webkit-scrollbar-thumb]:dark:bg-[#2a2d31]
[&::-webkit-scrollbar-thumb]:rounded-full
placeholder:text-gray-500 dark:placeholder:text-gray-400
`}
/>
</div>
{/* Send button */}
<Button
size="sm"
isIconOnly
disabled={disabled || loading || !input.trim()}
onPress={handleInput}
className={`
transition-all duration-200
${input.trim()
? 'bg-indigo-50 hover:bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:hover:bg-indigo-800/60 dark:text-indigo-300'
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500'
}
scale-100 hover:scale-105 active:scale-95
disabled:opacity-50 disabled:scale-95
hover:shadow-md dark:hover:shadow-indigo-950/10
mb-0.5
`}
>
{loading ? (
<Spinner size="sm" color={input.trim() ? "primary" : "default"} />
) : (
<SendIcon
size={16}
className={`transform transition-transform ${isFocused ? 'translate-x-0.5' : ''}`}
/>
)}
</Button>
</div>
</div>
);
}
// Custom SendIcon component for better visual alignment
function SendIcon({ size, className }: { size: number, className?: string }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M22 2L11 13" />
<path d="M22 2L15 22L11 13L2 9L22 2Z" />
</svg>
);
}

View file

@ -32,18 +32,22 @@ export function ComposeBox({
const inputRef = useRef<HTMLTextAreaElement>(null);
function handleInput() {
console.log('handleInput called');
const prompt = input.trim();
if (!prompt) {
console.log('Prompt is empty, returning');
return;
}
// Clear input before calling handleUserMessage
console.log('Clearing input');
setInput('');
if (inputRef.current) {
inputRef.current.value = '';
}
console.log('Calling handleUserMessage with prompt:', prompt);
handleUserMessage(prompt);
console.log('handleInput completed');
}
function handleInputKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
@ -60,6 +64,10 @@ export function ComposeBox({
}
}, [messages, input]);
useEffect(() => {
console.log('Input state changed to:', input);
}, [input]);
return (
<div className="relative group">
{/* Keyboard shortcut hint */}