Skip to content
Merged
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
3 changes: 3 additions & 0 deletions ui/desktop/src/components/BaseChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ interface BaseChatProps {
disableSearch?: boolean; // Disable search functionality (for Hub)
showPopularTopics?: boolean; // Show popular chat topics in empty state (for Pair)
suppressEmptyState?: boolean; // Suppress empty state content (for transitions)
autoSubmit?: boolean;
}

function BaseChatContent({
Expand All @@ -112,6 +113,7 @@ function BaseChatContent({
disableSearch = false,
showPopularTopics = false,
suppressEmptyState = false,
autoSubmit = false,
}: BaseChatProps) {
const location = useLocation();
const scrollRef = useRef<ScrollAreaHandle>(null);
Expand Down Expand Up @@ -538,6 +540,7 @@ function BaseChatContent({
recipeConfig={recipeConfig}
recipeAccepted={recipeAccepted}
initialPrompt={initialPrompt}
autoSubmit={autoSubmit}
{...customChatInputProps}
/>
</div>
Expand Down
138 changes: 82 additions & 56 deletions ui/desktop/src/components/ChatInput.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useRef, useState, useEffect, useMemo } from 'react';
import React, { useRef, useState, useEffect, useMemo, useCallback } from 'react';
import { FolderKey, ScrollText } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/Tooltip';
import { Button } from './ui/button';
Expand Down Expand Up @@ -84,6 +84,7 @@ interface ChatInputProps {
recipeConfig?: Recipe | null;
recipeAccepted?: boolean;
initialPrompt?: string;
autoSubmit: boolean;
}

export default function ChatInput({
Expand All @@ -106,6 +107,7 @@ export default function ChatInput({
recipeConfig,
recipeAccepted,
initialPrompt,
autoSubmit = false,
}: ChatInputProps) {
const [_value, setValue] = useState(initialValue);
const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback
Expand Down Expand Up @@ -357,6 +359,7 @@ export default function ChatInput({
const [hasUserTyped, setHasUserTyped] = useState(false);
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const timeoutRefsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
const [didAutoSubmit, setDidAutoSubmit] = useState<boolean>(false);

// Use shared file drop hook for ChatInput
const {
Expand All @@ -367,7 +370,10 @@ export default function ChatInput({
} = useFileDrop();

// Merge local dropped files with parent dropped files
const allDroppedFiles = [...droppedFiles, ...localDroppedFiles];
const allDroppedFiles = useMemo(
() => [...droppedFiles, ...localDroppedFiles],
[droppedFiles, localDroppedFiles]
);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this help?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's a dependency of a hook now, so we don't want to make a new list every time


const handleRemoveDroppedFile = (idToRemove: string) => {
// Remove from local dropped files
Expand Down Expand Up @@ -936,69 +942,89 @@ export default function ChatInput({
return true; // Return true if message was queued
};

const performSubmit = () => {
const validPastedImageFilesPaths = pastedImages
.filter((img) => img.filePath && !img.error && !img.isLoading)
.map((img) => img.filePath as string);

// Get paths from all dropped files (both parent and local)
const droppedFilePaths = allDroppedFiles
.filter((file) => !file.error && !file.isLoading)
.map((file) => file.path);

let textToSend = displayValue.trim();
const performSubmit = useCallback(
(text?: string) => {
const validPastedImageFilesPaths = pastedImages
.filter((img) => img.filePath && !img.error && !img.isLoading)
.map((img) => img.filePath as string);
// Get paths from all dropped files (both parent and local)
const droppedFilePaths = allDroppedFiles
.filter((file) => !file.error && !file.isLoading)
.map((file) => file.path);

let textToSend = text ?? displayValue.trim();

// Combine pasted images and dropped files
const allFilePaths = [...validPastedImageFilesPaths, ...droppedFilePaths];
if (allFilePaths.length > 0) {
const pathsString = allFilePaths.join(' ');
textToSend = textToSend ? `${textToSend} ${pathsString}` : pathsString;
}

// Combine pasted images and dropped files
const allFilePaths = [...validPastedImageFilesPaths, ...droppedFilePaths];
if (allFilePaths.length > 0) {
const pathsString = allFilePaths.join(' ');
textToSend = textToSend ? `${textToSend} ${pathsString}` : pathsString;
}
if (textToSend) {
if (displayValue.trim()) {
LocalMessageStorage.addMessage(displayValue);
} else if (allFilePaths.length > 0) {
LocalMessageStorage.addMessage(allFilePaths.join(' '));
}

if (textToSend) {
if (displayValue.trim()) {
LocalMessageStorage.addMessage(displayValue);
} else if (allFilePaths.length > 0) {
LocalMessageStorage.addMessage(allFilePaths.join(' '));
}
handleSubmit(
new CustomEvent('submit', { detail: { value: textToSend } }) as unknown as React.FormEvent
);

handleSubmit(
new CustomEvent('submit', { detail: { value: textToSend } }) as unknown as React.FormEvent
);
// Auto-resume queue after sending a NON-interruption message (if it was paused due to interruption)
if (
queuePausedRef.current &&
lastInterruption &&
textToSend &&
!detectInterruption(textToSend)
) {
queuePausedRef.current = false;
setLastInterruption(null);
}

// Auto-resume queue after sending a NON-interruption message (if it was paused due to interruption)
if (
queuePausedRef.current &&
lastInterruption &&
textToSend &&
!detectInterruption(textToSend)
) {
queuePausedRef.current = false;
setLastInterruption(null);
}
setDisplayValue('');
setValue('');
setPastedImages([]);
setHistoryIndex(-1);
setSavedInput('');
setIsInGlobalHistory(false);
setHasUserTyped(false);

setDisplayValue('');
setValue('');
setPastedImages([]);
setHistoryIndex(-1);
setSavedInput('');
setIsInGlobalHistory(false);
setHasUserTyped(false);
// Clear draft when message is sent
if (chatContext && chatContext.clearDraft) {
chatContext.clearDraft();
}

// Clear draft when message is sent
if (chatContext && chatContext.clearDraft) {
chatContext.clearDraft();
// Clear both parent and local dropped files after processing
if (onFilesProcessed && droppedFiles.length > 0) {
onFilesProcessed();
}
if (localDroppedFiles.length > 0) {
setLocalDroppedFiles([]);
}
}
},
[
allDroppedFiles,
chatContext,
displayValue,
droppedFiles.length,
handleSubmit,
lastInterruption,
localDroppedFiles.length,
onFilesProcessed,
pastedImages,
setLocalDroppedFiles,
]
);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what happened here? did we not get warnings about this?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The key change here is running performSubmit() in a useEffect, but in order to do that I wrapped that in a useCallback, and that meant enumerating its dependencies.

We could have gotten away with keeping this a function re-created on every render, since the submit effect should only actually do something just once anyway, but we'd have to suppress some warnings and potentially invite some future bugs. Using hooks for all of them seemed the right way to go


// Clear both parent and local dropped files after processing
if (onFilesProcessed && droppedFiles.length > 0) {
onFilesProcessed();
}
if (localDroppedFiles.length > 0) {
setLocalDroppedFiles([]);
}
useEffect(() => {
if (!!autoSubmit && !didAutoSubmit) {
setDidAutoSubmit(true);
performSubmit(initialValue);
}
};
}, [autoSubmit, didAutoSubmit, initialValue, performSubmit]);

const handleKeyDown = (evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
// If mention popover is open, handle arrow keys and enter
Expand Down
1 change: 1 addition & 0 deletions ui/desktop/src/components/hub.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export default function Hub({

<ChatInput
handleSubmit={handleSubmit}
autoSubmit={false}
chatState={ChatState.Idle}
onStop={() => {}}
commandHistory={[]}
Expand Down
76 changes: 14 additions & 62 deletions ui/desktop/src/components/pair.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,47 +121,6 @@ export default function Pair({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.state, hasProcessedInitialInput, initialMessage]);

// Auto-submit the initial message after it's been set and component is ready
useEffect(() => {
if (shouldAutoSubmit && initialMessage) {
// Wait for the component to be fully rendered
const timer = setTimeout(() => {
// Try to trigger form submission programmatically
const textarea = document.querySelector(
'textarea[data-testid="chat-input"]'
) as HTMLTextAreaElement;
const form = textarea?.closest('form');

if (textarea && form) {
// Set the textarea value
textarea.value = initialMessage;
// eslint-disable-next-line no-undef
textarea.dispatchEvent(new Event('input', { bubbles: true }));

// Focus the textarea
textarea.focus();

// Simulate Enter key press to trigger submission
const enterEvent = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
which: 13,
bubbles: true,
});
textarea.dispatchEvent(enterEvent);

setShouldAutoSubmit(false);
}
}, 500); // Give more time for the component to fully mount

return () => clearTimeout(timer);
}

// Return undefined when condition is not met
return undefined;
}, [shouldAutoSubmit, initialMessage]);

// Custom message submit handler
const handleMessageSubmit = (message: string) => {
// This is called after a message is submitted
Expand All @@ -186,27 +145,20 @@ export default function Pair({
initialValue,
};

// Custom content before messages
const renderBeforeMessages = () => {
return <div>{/* Any Pair-specific content before messages can go here */}</div>;
};

return (
<>
<BaseChat
chat={chat}
setChat={setChat}
setView={setView}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
enableLocalStorage={true} // Enable local storage for Pair mode
onMessageSubmit={handleMessageSubmit}
onMessageStreamFinish={handleMessageStreamFinish}
renderBeforeMessages={renderBeforeMessages}
customChatInputProps={customChatInputProps}
contentClassName={cn('pr-1 pb-10', (isMobile || sidebarState === 'collapsed') && 'pt-11')} // Use dynamic content class with mobile margin and sidebar state
showPopularTopics={!isTransitioningFromHub} // Don't show popular topics while transitioning from Hub
suppressEmptyState={isTransitioningFromHub} // Suppress all empty state content while transitioning from Hub
/>
</>
<BaseChat
chat={chat}
autoSubmit={shouldAutoSubmit}
setChat={setChat}
setView={setView}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
enableLocalStorage={true} // Enable local storage for Pair mode
onMessageSubmit={handleMessageSubmit}
onMessageStreamFinish={handleMessageStreamFinish}
customChatInputProps={customChatInputProps}
contentClassName={cn('pr-1 pb-10', (isMobile || sidebarState === 'collapsed') && 'pt-11')} // Use dynamic content class with mobile margin and sidebar state
showPopularTopics={!isTransitioningFromHub} // Don't show popular topics while transitioning from Hub
suppressEmptyState={isTransitioningFromHub} // Suppress all empty state content while transitioning from Hub
/>
);
}
Loading