{/* Hidden file input */}
diff --git a/src/components/features/chat/interactive-chat-box.tsx b/src/components/features/chat/interactive-chat-box.tsx
index b530066f4..b65cd686c 100644
--- a/src/components/features/chat/interactive-chat-box.tsx
+++ b/src/components/features/chat/interactive-chat-box.tsx
@@ -1,17 +1,15 @@
-import { isFileImage } from "#/utils/is-file-image";
-import { displayErrorToast } from "#/utils/custom-toast-handlers";
-import { validateFiles } from "#/utils/file-validation";
import { CustomChatInput } from "./custom-chat-input";
import { useBtwInterceptor } from "#/hooks/chat/use-btw-interceptor";
import { useModelInterceptor } from "#/hooks/chat/use-model-interceptor";
+import { useChatAttachmentUpload } from "#/hooks/chat/use-chat-attachment-upload";
import { AgentState } from "#/types/agent-state";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useOptionalConversationId } from "#/hooks/use-conversation-id";
import { GitControlBar } from "./git-control-bar";
import { useConversationStore } from "#/stores/conversation-store";
import { useAgentState } from "#/hooks/use-agent-state";
-import { processFiles, processImages } from "#/utils/file-processing";
import { useSubConversationTaskPolling } from "#/hooks/query/use-sub-conversation-task-polling";
+import { partitionImagesForUpload } from "#/components/features/chat/utils/chat-input.utils";
import { isTaskPolling } from "#/utils/utils";
interface InteractiveChatBoxProps {
@@ -26,135 +24,29 @@ export function InteractiveChatBox({
const {
images,
files,
- uploadImagesAsFiles,
- addImages,
- addFiles,
+ imagesMarkedUploadAsFile,
clearAllFiles,
- addFileLoading,
- removeFileLoading,
- addImageLoading,
- removeImageLoading,
subConversationTaskId,
} = useConversationStore();
const { curAgentState } = useAgentState();
const { data: conversation } = useActiveConversation();
- // URL-based id is available the instant we land on the conversation route;
- // `conversation?.id` is `undefined` until the first fetch resolves, which
- // would let an early `/model NAME` (first action) fall through unhandled.
const { conversationId: routeConversationId } = useOptionalConversationId();
const conversationId = routeConversationId ?? conversation?.id ?? null;
- // Poll sub-conversation task to check if it's loading
const { taskStatus: subConversationTaskStatus } =
useSubConversationTaskPolling(
subConversationTaskId,
conversation?.id || null,
);
- // Helper function to validate and filter files
- const validateAndFilterFiles = (selectedFiles: File[]) => {
- const validation = validateFiles(selectedFiles, [...images, ...files]);
-
- if (!validation.isValid) {
- displayErrorToast(`Error: ${validation.errorMessage}`);
- return null;
- }
-
- const validFiles = selectedFiles.filter((f) => !isFileImage(f));
- const validImages = selectedFiles.filter((f) => isFileImage(f));
-
- return { validFiles, validImages };
- };
-
- // Helper function to show loading indicators for files
- const showLoadingIndicators = (validFiles: File[], validImages: File[]) => {
- validFiles.forEach((file) => addFileLoading(file.name));
- validImages.forEach((image) => addImageLoading(image.name));
- };
-
- // Helper function to handle successful file processing results
- const handleSuccessfulFiles = (fileResults: { successful: File[] }) => {
- if (fileResults.successful.length > 0) {
- addFiles(fileResults.successful);
- fileResults.successful.forEach((file) => removeFileLoading(file.name));
- }
- };
-
- // Helper function to handle successful image processing results
- const handleSuccessfulImages = (imageResults: { successful: File[] }) => {
- if (imageResults.successful.length > 0) {
- addImages(imageResults.successful);
- imageResults.successful.forEach((image) =>
- removeImageLoading(image.name),
- );
- }
- };
-
- // Helper function to handle failed file processing results
- const handleFailedFiles = (
- fileResults: { failed: { file: File; error: Error }[] },
- imageResults: { failed: { file: File; error: Error }[] },
- ) => {
- fileResults.failed.forEach(({ file, error }) => {
- removeFileLoading(file.name);
- displayErrorToast(
- `Failed to process file ${file.name}: ${error.message}`,
- );
- });
-
- imageResults.failed.forEach(({ file, error }) => {
- removeImageLoading(file.name);
- displayErrorToast(
- `Failed to process image ${file.name}: ${error.message}`,
- );
- });
- };
-
- // Helper function to clear loading states on error
- const clearLoadingStates = (validFiles: File[], validImages: File[]) => {
- validFiles.forEach((file) => removeFileLoading(file.name));
- validImages.forEach((image) => removeImageLoading(image.name));
- };
-
- const handleUpload = async (selectedFiles: File[]) => {
- // Step 1: Validate and filter files
- const result = validateAndFilterFiles(selectedFiles);
- if (!result) return;
-
- const { validFiles, validImages } = result;
-
- // Step 2: Show loading indicators immediately
- showLoadingIndicators(validFiles, validImages);
-
- // Step 3: Process files using REAL FileReader
- try {
- const [fileResults, imageResults] = await Promise.all([
- processFiles(validFiles),
- processImages(validImages),
- ]);
-
- // Step 4: Handle successful results
- handleSuccessfulFiles(fileResults);
- handleSuccessfulImages(imageResults);
-
- // Step 5: Handle failed results
- handleFailedFiles(fileResults, imageResults);
- } catch {
- // Clear loading states and show error
- clearLoadingStates(validFiles, validImages);
- displayErrorToast("An unexpected error occurred while processing files");
- }
- };
+ const { handleUpload } = useChatAttachmentUpload();
const handleAfterModel = useBtwInterceptor(conversationId, (message) => {
- // When the user opts in via the "upload as file" checkbox, route
- // the attached images through the normal file-upload path instead
- // of embedding them in the message sent to the LLM.
- if (uploadImagesAsFiles) {
- onSubmit(message, [], [...files, ...images]);
- } else {
- onSubmit(message, images, files);
- }
+ const { imagesToEmbed, imagesAsFiles } = partitionImagesForUpload(
+ images,
+ imagesMarkedUploadAsFile,
+ );
+ onSubmit(message, imagesToEmbed, [...files, ...imagesAsFiles]);
clearAllFiles();
});
const handleSubmit = useModelInterceptor(conversationId, handleAfterModel);
@@ -163,8 +55,6 @@ export function InteractiveChatBox({
handleSubmit(suggestion);
};
- // Allow users to submit messages during LOADING state - they will be
- // queued server-side and delivered when the conversation becomes ready
const isDisabled =
disabled ||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION ||
diff --git a/src/components/features/chat/pasted-image-upload-as-file-button.tsx b/src/components/features/chat/pasted-image-upload-as-file-button.tsx
new file mode 100644
index 000000000..1ad77260d
--- /dev/null
+++ b/src/components/features/chat/pasted-image-upload-as-file-button.tsx
@@ -0,0 +1,51 @@
+import { Check, FilePlus } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip";
+import { I18nKey } from "#/i18n/declaration";
+import { cn } from "#/utils/utils";
+
+interface PastedImageUploadAsFileButtonProps {
+ active: boolean;
+ onToggle: () => void;
+}
+
+export function PastedImageUploadAsFileButton({
+ active,
+ onToggle,
+}: PastedImageUploadAsFileButtonProps) {
+ const { t } = useTranslation("openhands");
+ const uploadLabel = t(I18nKey.CHAT_INTERFACE$UPLOAD_IMAGES_AS_FILES);
+ const doNotUploadLabel = t(I18nKey.CHAT_INTERFACE$DO_NOT_UPLOAD_AS_FILE);
+ const label = active ? doNotUploadLabel : uploadLabel;
+
+ return (
+
+
+ {
+ e.stopPropagation();
+ onToggle();
+ }}
+ className={cn(
+ "flex h-4 w-4 items-center justify-center rounded-full bg-[var(--oh-surface)] text-[var(--oh-foreground)] transition-colors cursor-pointer hover:bg-[var(--oh-muted)]",
+ )}
+ >
+ {active ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/features/chat/pending-user-messages.tsx b/src/components/features/chat/pending-user-messages.tsx
index 004f6eb4b..9efeb51b5 100644
--- a/src/components/features/chat/pending-user-messages.tsx
+++ b/src/components/features/chat/pending-user-messages.tsx
@@ -3,6 +3,8 @@ import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-
import { useSendMessage } from "#/hooks/use-send-message";
import { createChatMessage } from "#/services/chat-service";
import { useOptionalConversationId } from "#/hooks/use-conversation-id";
+import { matchesPendingConversationId } from "#/utils/pending-task-message-link";
+import { ImageCarousel } from "#/components/features/images/image-carousel";
import { ChatMessage } from "./chat-message";
/**
@@ -33,8 +35,11 @@ export function PendingUserMessages() {
const visibleMessages = React.useMemo(
() =>
conversationId
- ? pendingMessages.filter(
- (message) => message.conversationId === conversationId,
+ ? pendingMessages.filter((message) =>
+ matchesPendingConversationId(
+ conversationId,
+ message.conversationId,
+ ),
)
: [],
[pendingMessages, conversationId],
@@ -84,7 +89,11 @@ export function PendingUserMessages() {
? () => handleRetry(message.id)
: undefined
}
- />
+ >
+ {message.imageUrls.length > 0 && (
+
+ )}
+
))}
>
);
diff --git a/src/components/features/chat/remove-file-button.tsx b/src/components/features/chat/remove-file-button.tsx
index bd5fc14d7..cf5c8d796 100644
--- a/src/components/features/chat/remove-file-button.tsx
+++ b/src/components/features/chat/remove-file-button.tsx
@@ -13,7 +13,7 @@ export function RemoveFileButton({ onClick }: RemoveFileButtonProps) {
type="button"
onClick={onClick}
className={cn(
- "flex w-4 h-4 rounded-full items-center justify-center bg-[var(--oh-surface)] hover:bg-[var(--oh-muted)] cursor-pointer absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200",
+ "z-10 flex w-4 h-4 rounded-full items-center justify-center bg-[var(--oh-surface)] hover:bg-[var(--oh-muted)] cursor-pointer absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200",
isMobile && "opacity-100",
)}
>
diff --git a/src/components/features/chat/upload-as-file-checkbox.tsx b/src/components/features/chat/upload-as-file-checkbox.tsx
deleted file mode 100644
index 6f9e9c752..000000000
--- a/src/components/features/chat/upload-as-file-checkbox.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import { useTranslation } from "react-i18next";
-import { I18nKey } from "#/i18n/declaration";
-import { useConversationStore } from "#/stores/conversation-store";
-
-export function UploadAsFileCheckbox() {
- const { t } = useTranslation("openhands");
- const { uploadImagesAsFiles, setUploadImagesAsFiles } =
- useConversationStore();
-
- return (
-
- setUploadImagesAsFiles(e.target.checked)}
- className="h-3.5 w-3.5 flex-shrink-0"
- />
- {t(I18nKey.CHAT_INTERFACE$UPLOAD_IMAGES_AS_FILES)}
-
- );
-}
diff --git a/src/components/features/chat/uploaded-files.tsx b/src/components/features/chat/uploaded-files.tsx
index 2540eb0c2..ebfd2ff7b 100644
--- a/src/components/features/chat/uploaded-files.tsx
+++ b/src/components/features/chat/uploaded-files.tsx
@@ -1,6 +1,5 @@
import { UploadedFile } from "./uploaded-file";
import { UploadedImage } from "./uploaded-image";
-import { UploadAsFileCheckbox } from "./upload-as-file-checkbox";
import { useConversationStore } from "#/stores/conversation-store";
export function UploadedFiles() {
@@ -9,12 +8,12 @@ export function UploadedFiles() {
files,
loadingFiles,
loadingImages,
+ imagesMarkedUploadAsFile,
removeFile,
removeImage,
+ toggleImageUploadAsFile,
} = useConversationStore();
- const hasImages = images.length > 0 || loadingImages.length > 0;
-
const handleRemoveFile = (index: number) => {
removeFile(index);
};
@@ -34,8 +33,8 @@ export function UploadedFiles() {
}
return (
-
-
+
+
{/* Regular files */}
{files.map((file, index) => (
handleRemoveImage(index)}
isLoading={loadingImages.includes(image.name)}
+ showUploadAsFileToggle
+ uploadAsFileActive={imagesMarkedUploadAsFile.includes(image.name)}
+ onToggleUploadAsFile={() => toggleImageUploadAsFile(image.name)}
/>
))}
@@ -80,12 +82,13 @@ export function UploadedFiles() {
image={tempImage}
onRemove={() => {}} // No remove action during loading
isLoading
+ showUploadAsFileToggle
+ uploadAsFileActive={imagesMarkedUploadAsFile.includes(imageName)}
+ onToggleUploadAsFile={() => toggleImageUploadAsFile(imageName)}
/>
);
})}
-
- {hasImages &&
}
);
}
diff --git a/src/components/features/chat/uploaded-image.tsx b/src/components/features/chat/uploaded-image.tsx
index 0f27a90c2..8601a5342 100644
--- a/src/components/features/chat/uploaded-image.tsx
+++ b/src/components/features/chat/uploaded-image.tsx
@@ -1,17 +1,24 @@
import React from "react";
import { LoaderCircle } from "lucide-react";
+import { PastedImageUploadAsFileButton } from "./pasted-image-upload-as-file-button";
import { RemoveFileButton } from "./remove-file-button";
interface UploadedImageProps {
image: File;
onRemove: () => void;
isLoading?: boolean;
+ showUploadAsFileToggle?: boolean;
+ uploadAsFileActive?: boolean;
+ onToggleUploadAsFile?: () => void;
}
export function UploadedImage({
image,
onRemove,
isLoading = false,
+ showUploadAsFileToggle = false,
+ uploadAsFileActive = false,
+ onToggleUploadAsFile,
}: UploadedImageProps) {
const [imageUrl, setImageUrl] = React.useState
("");
@@ -27,8 +34,7 @@ export function UploadedImage({
}, [image]);
return (
-
-
+
{isLoading ? (
) : (
@@ -36,10 +42,17 @@ export function UploadedImage({
)
)}
+
+ {showUploadAsFileToggle && onToggleUploadAsFile && (
+
+ )}
);
}
diff --git a/src/components/features/chat/utils/chat-input.utils.ts b/src/components/features/chat/utils/chat-input.utils.ts
index b08107259..a91dd00db 100644
--- a/src/components/features/chat/utils/chat-input.utils.ts
+++ b/src/components/features/chat/utils/chat-input.utils.ts
@@ -2,6 +2,85 @@
* Utility functions for chat input component
*/
/* eslint-disable no-param-reassign */
+
+const CLIPBOARD_IMAGE_EXTENSIONS: Record
= {
+ "image/png": "png",
+ "image/jpeg": "jpg",
+ "image/jpg": "jpg",
+ "image/gif": "gif",
+ "image/webp": "webp",
+ "image/bmp": "bmp",
+};
+
+/**
+ * Screenshots and copied images are often exposed only via
+ * `clipboardData.items` (not `clipboardData.files`). Normalize unnamed
+ * clipboard files so validation and loading UI have stable labels.
+ */
+export function normalizePastedFile(file: File): File {
+ if (file.name.trim()) {
+ return file;
+ }
+
+ const extension =
+ CLIPBOARD_IMAGE_EXTENSIONS[file.type] ??
+ (file.type.startsWith("image/") ? "png" : "bin");
+
+ return new File([file], `pasted-image-${Date.now()}.${extension}`, {
+ type: file.type,
+ lastModified: file.lastModified,
+ });
+}
+
+/** Matches names assigned by {@link normalizePastedFile} for clipboard screenshots. */
+export const PASTED_CLIPBOARD_IMAGE_NAME = /^pasted-image-\d+\.[a-z0-9]+$/i;
+
+export function isPastedClipboardImage(file: File): boolean {
+ return PASTED_CLIPBOARD_IMAGE_NAME.test(file.name);
+}
+
+export function partitionImagesForUpload(
+ images: File[],
+ markedUploadAsFileNames: readonly string[],
+): { imagesToEmbed: File[]; imagesAsFiles: File[] } {
+ const marked = new Set(markedUploadAsFileNames);
+ const imagesToEmbed: File[] = [];
+ const imagesAsFiles: File[] = [];
+
+ for (const image of images) {
+ if (marked.has(image.name)) {
+ imagesAsFiles.push(image);
+ } else {
+ imagesToEmbed.push(image);
+ }
+ }
+
+ return { imagesToEmbed, imagesAsFiles };
+}
+
+/**
+ * Collect files from a paste event, including clipboard image items.
+ */
+export function getClipboardFiles(clipboardData: DataTransfer): File[] {
+ const fromFileList = Array.from(clipboardData.files);
+ if (fromFileList.length > 0) {
+ return fromFileList.map(normalizePastedFile);
+ }
+
+ const fromItems: File[] = [];
+ for (let i = 0; i < clipboardData.items.length; i += 1) {
+ const item = clipboardData.items[i];
+ if (item.kind !== "file") {
+ continue;
+ }
+ const file = item.getAsFile();
+ if (file) {
+ fromItems.push(normalizePastedFile(file));
+ }
+ }
+
+ return fromItems;
+}
/**
* Check if contentEditable element is truly empty
*/
diff --git a/src/components/features/controls/tools-context-menu.tsx b/src/components/features/controls/tools-context-menu.tsx
index 0df9fcef1..c7cc218ec 100644
--- a/src/components/features/controls/tools-context-menu.tsx
+++ b/src/components/features/controls/tools-context-menu.tsx
@@ -26,6 +26,13 @@ interface ToolsContextMenuProps {
onShowAgentTools: (event: React.MouseEvent) => void;
shouldShowAgentTools?: boolean;
shouldShowHooks?: boolean;
+ /** When set, renders a divider and this action as the last menu item. */
+ footerAction?: {
+ testId: string;
+ icon: React.ReactNode;
+ label: string;
+ onClick: () => void;
+ };
}
export function ToolsContextMenu({
@@ -35,6 +42,7 @@ export function ToolsContextMenu({
onShowAgentTools,
shouldShowAgentTools = true,
shouldShowHooks = false,
+ footerAction,
}: ToolsContextMenuProps) {
const { t } = useTranslation("openhands");
const { data: conversation } = useActiveConversation();
@@ -157,6 +165,26 @@ export function ToolsContextMenu({
/>
)}
+
+ {footerAction && (
+ <>
+
+ {
+ event.preventDefault();
+ event.stopPropagation();
+ footerAction.onClick();
+ handleClose();
+ }}
+ >
+
+
+ >
+ )}
);
}
diff --git a/src/components/features/home/home-chat-launcher.tsx b/src/components/features/home/home-chat-launcher.tsx
index 29696e129..c70ce37ab 100644
--- a/src/components/features/home/home-chat-launcher.tsx
+++ b/src/components/features/home/home-chat-launcher.tsx
@@ -6,6 +6,11 @@ import { useActiveBackend } from "#/contexts/active-backend-context";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { useLocalWorkspaces } from "#/hooks/query/use-local-workspaces";
import { useModelInterceptor } from "#/hooks/chat/use-model-interceptor";
+import { useChatAttachmentUpload } from "#/hooks/chat/use-chat-attachment-upload";
+import { useConversationStore } from "#/stores/conversation-store";
+import { setPendingTaskAttachments } from "#/stores/pending-task-attachments-store";
+import { enqueueHomeTaskPendingMessage } from "#/utils/enqueue-home-task-pending-message";
+import { sendMessageWithAttachments } from "#/utils/send-message-with-attachments";
import { useNavigation } from "#/context/navigation-context";
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
import { Branch, GitRepository } from "#/types/git";
@@ -40,6 +45,9 @@ export function HomeChatLauncher() {
const { mutate: createConversation, isPending } = useCreateConversation();
const isCreatingElsewhere = useIsCreatingConversation();
const isCreating = isPending || isCreatingElsewhere;
+ const { images, files, imagesMarkedUploadAsFile, clearAllFiles } =
+ useConversationStore();
+ const { handleUpload } = useChatAttachmentUpload();
const { error: workspacesError } = useLocalWorkspaces({ enabled: isLocal });
const workspacesUnsupportedMessage = isLocal
? getWorkspacesUnsupportedMessage(workspacesError, t)
@@ -51,13 +59,22 @@ export function HomeChatLauncher() {
const handleSubmit = (message: string) => {
const trimmed = message.trim();
- if (!trimmed || isCreating) return;
+ const hasAttachments = images.length > 0 || files.length > 0;
+ if ((!trimmed && !hasAttachments) || isCreating) return;
+
+ const attachmentSnapshot = {
+ images: [...images],
+ files: [...files],
+ };
// Workspace/repo are optional — match the "Start from scratch" flow which
// creates a conversation with no working dir and no repo. Build the
// payload from whatever is selected.
+ // When attachments are present the first user message is sent afterward
+ // via sendMessageWithAttachments / flushPendingTaskAttachments. Passing
+ // query here would create a duplicate text-only initial_message.
let variables: Parameters[0] = {
- query: trimmed,
+ query: hasAttachments ? undefined : trimmed || undefined,
};
if (isLocal && pendingWorkspace) {
variables = { ...variables, workingDir: pendingWorkspace.path };
@@ -80,9 +97,71 @@ export function HomeChatLauncher() {
);
createConversation(variables, {
- onSuccess: (data) => {
+ onSuccess: async (data) => {
toast.dismiss(toastId);
- navigate(`/conversations/${data.conversation_id}`);
+ const targetConversationId = data.conversation_id;
+ const isTaskConversation = targetConversationId.startsWith("task-");
+
+ if (hasAttachments) {
+ // Cloud sandboxes provision asynchronously; uploads and the first
+ // message must target the runtime URL, not the bundled local server.
+ const shouldDeferAttachments = !isLocal || isTaskConversation;
+
+ if (shouldDeferAttachments) {
+ const taskId =
+ data.task_id ??
+ (isTaskConversation
+ ? targetConversationId.slice("task-".length)
+ : null);
+
+ if (!taskId) {
+ displayErrorToast(null);
+ return;
+ }
+
+ setPendingTaskAttachments(taskId, {
+ content: trimmed,
+ images: attachmentSnapshot.images,
+ files: attachmentSnapshot.files,
+ imagesMarkedUploadAsFile: [...imagesMarkedUploadAsFile],
+ });
+ clearAllFiles();
+ await enqueueHomeTaskPendingMessage({
+ conversationId: targetConversationId,
+ text: trimmed,
+ images: attachmentSnapshot.images,
+ imagesMarkedUploadAsFile,
+ });
+ navigate(`/conversations/${targetConversationId}`);
+ return;
+ } else {
+ try {
+ await sendMessageWithAttachments({
+ conversationId: targetConversationId,
+ content: trimmed,
+ images: attachmentSnapshot.images,
+ files: attachmentSnapshot.files,
+ imagesMarkedUploadAsFile,
+ t,
+ });
+ clearAllFiles();
+ } catch (error) {
+ displayErrorToast(error instanceof Error ? error.message : null);
+ return;
+ }
+ }
+ }
+
+ if (isTaskConversation && trimmed) {
+ await enqueueHomeTaskPendingMessage({
+ conversationId: targetConversationId,
+ text: trimmed,
+ images: [],
+ imagesMarkedUploadAsFile: [],
+ });
+ }
+
+ navigate(`/conversations/${targetConversationId}`);
},
onError: (error) => {
toast.dismiss(toastId);
@@ -109,6 +188,7 @@ export function HomeChatLauncher() {
diff --git a/src/components/shared/buttons/styled-tooltip.tsx b/src/components/shared/buttons/styled-tooltip.tsx
index 7a1a1e46e..a99f451fc 100644
--- a/src/components/shared/buttons/styled-tooltip.tsx
+++ b/src/components/shared/buttons/styled-tooltip.tsx
@@ -10,6 +10,7 @@ export interface StyledTooltipProps {
showArrow?: boolean;
closeDelay?: number;
offset?: number;
+ shouldFlip?: boolean;
}
function getTooltipTriggerChild(children: ReactNode) {
@@ -26,6 +27,7 @@ export function StyledTooltip({
placement = "right",
showArrow = false,
closeDelay = 100,
+ shouldFlip,
offset = 7,
}: StyledTooltipProps) {
const disableAnimation = import.meta.env.MODE === "test";
@@ -36,6 +38,8 @@ export function StyledTooltip({
closeDelay={closeDelay}
placement={placement}
offset={offset}
+ shouldFlip={shouldFlip}
+ className={cn("bg-white text-black", tooltipClassName)}
showArrow={showArrow}
disableAnimation={disableAnimation}
classNames={{
diff --git a/src/contexts/conversation-websocket-context.tsx b/src/contexts/conversation-websocket-context.tsx
index 7ca9c9c89..8e7f25be2 100644
--- a/src/contexts/conversation-websocket-context.tsx
+++ b/src/contexts/conversation-websocket-context.tsx
@@ -2,6 +2,7 @@ import React, {
createContext,
useContext,
useEffect,
+ useLayoutEffect,
useState,
useCallback,
useMemo,
@@ -215,7 +216,7 @@ export function ConversationWebSocketProvider({
const isLoadingHistoryMain = !!conversationId && isPreloadingHistory;
- useEffect(() => {
+ useLayoutEffect(() => {
if (!preloadedHistory || preloadedHistory.events.length === 0) {
return;
}
diff --git a/src/hooks/chat/use-chat-attachment-upload.ts b/src/hooks/chat/use-chat-attachment-upload.ts
new file mode 100644
index 000000000..760b3a585
--- /dev/null
+++ b/src/hooks/chat/use-chat-attachment-upload.ts
@@ -0,0 +1,102 @@
+import { useCallback } from "react";
+
+export type ChatAttachmentUploadOptions = {
+ fromPaste?: boolean;
+};
+import { isFileImage } from "#/utils/is-file-image";
+import { displayErrorToast } from "#/utils/custom-toast-handlers";
+import { validateFiles } from "#/utils/file-validation";
+import { processFiles, processImages } from "#/utils/file-processing";
+import { useConversationStore } from "#/stores/conversation-store";
+
+/**
+ * Shared attachment pipeline for home and conversation chat inputs.
+ */
+export function useChatAttachmentUpload() {
+ const {
+ images,
+ files,
+ addImages,
+ addFiles,
+ addFileLoading,
+ removeFileLoading,
+ addImageLoading,
+ removeImageLoading,
+ markImagesAsPasted,
+ } = useConversationStore();
+
+ const handleUpload = useCallback(
+ async (selectedFiles: File[], _options?: ChatAttachmentUploadOptions) => {
+ const validation = validateFiles(selectedFiles, [...images, ...files]);
+
+ if (!validation.isValid) {
+ displayErrorToast(`Error: ${validation.errorMessage}`);
+ return;
+ }
+
+ const validFiles = selectedFiles.filter((f) => !isFileImage(f));
+ const validImages = selectedFiles.filter((f) => isFileImage(f));
+
+ if (validImages.length > 0) {
+ markImagesAsPasted(validImages.map((image) => image.name));
+ }
+
+ validFiles.forEach((file) => addFileLoading(file.name));
+ validImages.forEach((image) => addImageLoading(image.name));
+
+ try {
+ const [fileResults, imageResults] = await Promise.all([
+ processFiles(validFiles),
+ processImages(validImages),
+ ]);
+
+ if (fileResults.successful.length > 0) {
+ addFiles(fileResults.successful);
+ fileResults.successful.forEach((file) =>
+ removeFileLoading(file.name),
+ );
+ }
+
+ if (imageResults.successful.length > 0) {
+ addImages(imageResults.successful);
+ imageResults.successful.forEach((image) =>
+ removeImageLoading(image.name),
+ );
+ }
+
+ fileResults.failed.forEach(({ file, error }) => {
+ removeFileLoading(file.name);
+ displayErrorToast(
+ `Failed to process file ${file.name}: ${error.message}`,
+ );
+ });
+
+ imageResults.failed.forEach(({ file, error }) => {
+ removeImageLoading(file.name);
+ displayErrorToast(
+ `Failed to process image ${file.name}: ${error.message}`,
+ );
+ });
+ } catch {
+ validFiles.forEach((file) => removeFileLoading(file.name));
+ validImages.forEach((image) => removeImageLoading(image.name));
+ displayErrorToast(
+ "An unexpected error occurred while processing files",
+ );
+ }
+ },
+ [
+ images,
+ files,
+ addImages,
+ addFiles,
+ addFileLoading,
+ removeFileLoading,
+ addImageLoading,
+ removeImageLoading,
+ markImagesAsPasted,
+ ],
+ );
+
+ return { handleUpload };
+}
diff --git a/src/hooks/chat/use-chat-input-events.ts b/src/hooks/chat/use-chat-input-events.ts
index e6ae1bcc6..c57899faa 100644
--- a/src/hooks/chat/use-chat-input-events.ts
+++ b/src/hooks/chat/use-chat-input-events.ts
@@ -3,6 +3,7 @@ import { isMobileDevice } from "#/utils/utils";
import {
ensureCursorVisible,
clearEmptyContent,
+ getClipboardFiles,
} from "#/components/features/chat/utils/chat-input.utils";
/**
@@ -35,8 +36,7 @@ export const useChatInputEvents = (
(e: React.ClipboardEvent) => {
e.preventDefault();
- // Check if there are files in the clipboard
- const files = Array.from(e.clipboardData.files);
+ const files = getClipboardFiles(e.clipboardData);
const hasFiles = files.length > 0;
if (hasFiles) {
diff --git a/src/hooks/chat/use-chat-submission.ts b/src/hooks/chat/use-chat-submission.ts
index 9455bb10a..287e41055 100644
--- a/src/hooks/chat/use-chat-submission.ts
+++ b/src/hooks/chat/use-chat-submission.ts
@@ -3,6 +3,7 @@ import {
clearTextContent,
clearFileInput,
} from "#/components/features/chat/utils/chat-input.utils";
+import { useConversationStore } from "#/stores/conversation-store";
/**
* Hook for handling chat message submission
@@ -18,8 +19,10 @@ export const useChatSubmission = (
const handleSubmit = useCallback(() => {
const message = chatInputRef.current?.innerText || "";
const trimmedMessage = message.trim();
+ const { images, files } = useConversationStore.getState();
+ const hasAttachments = images.length > 0 || files.length > 0;
- if (!trimmedMessage) {
+ if (!trimmedMessage && !hasAttachments) {
return;
}
diff --git a/src/hooks/chat/use-file-handling.ts b/src/hooks/chat/use-file-handling.ts
index 0e0c13481..86b6c7840 100644
--- a/src/hooks/chat/use-file-handling.ts
+++ b/src/hooks/chat/use-file-handling.ts
@@ -1,4 +1,5 @@
import React, { useRef, useCallback, useState, useEffect } from "react";
+import type { ChatAttachmentUploadOptions } from "#/hooks/chat/use-chat-attachment-upload";
interface UseFileHandlingReturn {
fileInputRef: React.RefObject;
@@ -15,7 +16,7 @@ interface UseFileHandlingReturn {
* Hook for handling file operations (upload, drag & drop)
*/
export const useFileHandling = (
- onFilesPaste?: (files: File[]) => void,
+ onFilesPaste?: (files: File[], options?: ChatAttachmentUploadOptions) => void,
): UseFileHandlingReturn => {
const fileInputRef = useRef(null);
const chatContainerRef = useRef(null);
@@ -23,9 +24,9 @@ export const useFileHandling = (
// Function to add files and notify parent
const addFiles = useCallback(
- (files: File[]) => {
+ (files: File[], options?: ChatAttachmentUploadOptions) => {
if (onFilesPaste && files.length > 0) {
- onFilesPaste(files);
+ onFilesPaste(files, options);
}
},
[onFilesPaste],
@@ -36,7 +37,7 @@ export const useFileHandling = (
const handlePasteFiles = (event: CustomEvent) => {
const files = event.detail.files as File[];
if (files && files.length > 0) {
- addFiles(files);
+ addFiles(files, { fromPaste: true });
}
};
diff --git a/src/hooks/mutation/use-conversation-upload-files.ts b/src/hooks/mutation/use-conversation-upload-files.ts
index 7145797bf..4e6cf669d 100644
--- a/src/hooks/mutation/use-conversation-upload-files.ts
+++ b/src/hooks/mutation/use-conversation-upload-files.ts
@@ -1,11 +1,16 @@
import { useMutation } from "@tanstack/react-query";
import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace";
import { getAgentServerClientOptions } from "#/api/agent-server-client-options";
+import {
+ buildWorkspaceUploadPath,
+ getSafeUploadFileName,
+} from "#/api/workspace-upload-path";
import { FileUploadSuccessResponse } from "#/api/open-hands.types";
interface UploadFilesVariables {
conversationUrl: string | null | undefined;
sessionApiKey: string | null | undefined;
+ workingDir: string;
files: File[];
}
@@ -21,22 +26,25 @@ export const useConversationUploadFiles = () =>
mutationFn: async (
variables: UploadFilesVariables,
): Promise => {
- const { conversationUrl, sessionApiKey, files } = variables;
+ const { conversationUrl, sessionApiKey, workingDir, files } = variables;
- // Upload all files in parallel
const uploadPromises = files.map(async (file) => {
try {
- // Upload to /workspace/{filename}
- const filePath = `/workspace/${file.name}`;
+ const safeName = getSafeUploadFileName(file.name);
+ const filePath = buildWorkspaceUploadPath(file.name, workingDir);
await new RemoteWorkspace(
- getAgentServerClientOptions({ conversationUrl, sessionApiKey }),
+ getAgentServerClientOptions({
+ conversationUrl,
+ sessionApiKey,
+ workingDir,
+ }),
).fileUpload(file, filePath);
- return { success: true as const, fileName: file.name, filePath };
+ return { success: true as const, fileName: safeName, filePath };
} catch (error) {
return {
success: false as const,
fileName: file.name,
- filePath: `/workspace/${file.name}`,
+ filePath: buildWorkspaceUploadPath(file.name, workingDir),
error: error instanceof Error ? error.message : "Unknown error",
};
}
diff --git a/src/hooks/mutation/use-unified-upload-files.ts b/src/hooks/mutation/use-unified-upload-files.ts
index bb4e0cf61..6ceed4438 100644
--- a/src/hooks/mutation/use-unified-upload-files.ts
+++ b/src/hooks/mutation/use-unified-upload-files.ts
@@ -1,6 +1,6 @@
import { useMutation } from "@tanstack/react-query";
+import { uploadFilesToConversation } from "#/api/conversation-file-upload.api";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
-import { useConversationUploadFiles } from "./use-conversation-upload-files";
import { FileUploadSuccessResponse } from "#/api/open-hands.types";
interface UnifiedUploadFilesVariables {
@@ -9,26 +9,18 @@ interface UnifiedUploadFilesVariables {
}
/**
- * Uploads files for the active agent-server conversation.
+ * Uploads files for the active conversation (local agent-server or cloud runtime).
*/
export const useUnifiedUploadFiles = () => {
const { data: conversation } = useActiveConversation();
- const conversationUpload = useConversationUploadFiles();
-
return useMutation({
mutationKey: ["unified-upload-files"],
mutationFn: async (
variables: UnifiedUploadFilesVariables,
): Promise => {
- const { files } = variables;
-
- // Use conversation URL and session API key
- return conversationUpload.mutateAsync({
- conversationUrl: conversation?.conversation_url,
- sessionApiKey: conversation?.session_api_key,
- files,
- });
+ const { conversationId, files } = variables;
+ return uploadFilesToConversation(conversationId, files, conversation);
},
meta: {
disableToast: true,
diff --git a/src/hooks/query/use-task-polling.ts b/src/hooks/query/use-task-polling.ts
index fa2a7e446..15c509996 100644
--- a/src/hooks/query/use-task-polling.ts
+++ b/src/hooks/query/use-task-polling.ts
@@ -1,4 +1,4 @@
-import { useEffect } from "react";
+import { useEffect, useLayoutEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
import { useNavigation } from "#/context/navigation-context";
@@ -7,6 +7,14 @@ import {
consumePendingTaskDraft,
setConversationState,
} from "#/utils/conversation-local-storage";
+import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
+import { flushPendingTaskAttachments } from "#/utils/flush-pending-task-attachments";
+import {
+ clearPendingTaskMessageLink,
+ consumeScheduledPendingTaskMessageReassign,
+ linkPendingTaskMessages,
+ schedulePendingTaskMessageReassign,
+} from "#/utils/pending-task-message-link";
/**
* Hook that polls V1 conversation start tasks and navigates when ready.
@@ -56,22 +64,70 @@ export const useTaskPolling = () => {
retry: false,
});
+ const handledReadyTaskIdRef = useRef(null);
+
+ // Reassign optimistic pending messages before paint on the real conversation
+ // route. Doing this in the ready handler before navigate leaves a frame where
+ // the URL still points at `task-{uuid}` but pending is keyed to the real id.
+ useLayoutEffect(() => {
+ if (!conversationId) {
+ return;
+ }
+
+ const pendingReassign =
+ consumeScheduledPendingTaskMessageReassign(conversationId);
+ if (!pendingReassign) {
+ return;
+ }
+
+ useOptimisticUserMessageStore
+ .getState()
+ .reassignPendingMessages(
+ pendingReassign.fromConversationId,
+ pendingReassign.toConversationId,
+ );
+ clearPendingTaskMessageLink(pendingReassign.toConversationId);
+ }, [conversationId]);
+
// Navigate to conversation ID when task is ready
useEffect(() => {
const task = taskQuery.data;
- if (task?.status === "READY" && task.app_conversation_id) {
+ if (
+ !taskId ||
+ task?.status !== "READY" ||
+ !task.app_conversation_id ||
+ handledReadyTaskIdRef.current === taskId
+ ) {
+ return;
+ }
+
+ handledReadyTaskIdRef.current = taskId;
+
+ void (async () => {
+ await flushPendingTaskAttachments(taskId, task.app_conversation_id!);
+
+ const taskConversationId = `task-${taskId}`;
+ linkPendingTaskMessages(task.app_conversation_id!, taskConversationId);
+ schedulePendingTaskMessageReassign(
+ taskConversationId,
+ task.app_conversation_id!,
+ );
+
const pendingDraft = consumePendingTaskDraft(taskId);
if (pendingDraft) {
- setConversationState(task.app_conversation_id, {
+ setConversationState(task.app_conversation_id!, {
draftMessage: pendingDraft,
});
}
- // Replace the URL with the actual conversation ID
navigate(`/conversations/${task.app_conversation_id}`, { replace: true });
- }
+ })();
}, [taskQuery.data, navigate, taskId]);
+ useEffect(() => {
+ handledReadyTaskIdRef.current = null;
+ }, [taskId]);
+
return {
isTask,
taskId,
diff --git a/src/hooks/use-load-older-events.ts b/src/hooks/use-load-older-events.ts
index 949637346..a67b06471 100644
--- a/src/hooks/use-load-older-events.ts
+++ b/src/hooks/use-load-older-events.ts
@@ -2,7 +2,11 @@ import React from "react";
import EventService from "#/api/event-service/event-service.api";
import { useUserConversation } from "#/hooks/query/use-user-conversation";
import { useEventStore } from "#/stores/use-event-store";
-import { INITIAL_HISTORY_PAGE_SIZE } from "#/hooks/query/use-conversation-history";
+import {
+ INITIAL_HISTORY_PAGE_SIZE,
+ useConversationHistory,
+} from "#/hooks/query/use-conversation-history";
+import { isTaskConversationId } from "#/utils/conversation-local-storage";
import type { OpenHandsEvent } from "#/types/agent-server/core";
const getEventTimestamp = (event: OpenHandsEvent): string | undefined =>
@@ -36,7 +40,13 @@ interface UseLoadOlderEventsResult {
export const useLoadOlderEvents = (
conversationId?: string | null,
): UseLoadOlderEventsResult => {
+ const isTaskConversation =
+ !!conversationId && isTaskConversationId(conversationId);
+ const realConversationId = isTaskConversation ? undefined : conversationId;
+
const { data: conversation } = useUserConversation(conversationId ?? null);
+ const { data: initialHistory, isFetched: isInitialHistoryFetched } =
+ useConversationHistory(realConversationId ?? undefined);
const addEvents = useEventStore((state) => state.addEvents);
const [isLoading, setIsLoading] = React.useState(false);
@@ -45,14 +55,50 @@ export const useLoadOlderEvents = (
const hasMoreRef = React.useRef(true);
React.useEffect(() => {
- hasMoreRef.current = true;
isLoadingRef.current = false;
- setHasMore(true);
setIsLoading(false);
- }, [conversationId]);
+
+ if (isTaskConversation) {
+ hasMoreRef.current = false;
+ setHasMore(false);
+ return;
+ }
+
+ hasMoreRef.current = true;
+ setHasMore(true);
+ }, [conversationId, isTaskConversation]);
+
+ // Mirror the initial REST page: if the tail fetch already returned
+ // everything, don't auto-trigger an older-events request on short chats.
+ React.useEffect(() => {
+ if (isTaskConversation || !isInitialHistoryFetched || !initialHistory) {
+ return;
+ }
+ if (!initialHistory.hasMore) {
+ hasMoreRef.current = false;
+ setHasMore(false);
+ }
+ }, [
+ isTaskConversation,
+ isInitialHistoryFetched,
+ initialHistory?.hasMore,
+ realConversationId,
+ ]);
const loadOlder = React.useCallback(async () => {
- if (!conversationId || isLoadingRef.current || !hasMoreRef.current) {
+ if (
+ !conversationId ||
+ isTaskConversationId(conversationId) ||
+ isLoadingRef.current ||
+ !hasMoreRef.current
+ ) {
+ return;
+ }
+
+ // Cloud/local metadata (runtime URL, session key) isn't available on
+ // start-task placeholder routes and may still be loading right after
+ // redirect from `/conversations/task-{uuid}`.
+ if (!conversation) {
return;
}
@@ -65,11 +111,11 @@ export const useLoadOlderEvents = (
const oldestTimestamp = getEventTimestamp(oldest);
if (!oldestTimestamp) {
+ // Nothing paginate-able — treat as exhausted rather than surfacing an
+ // error banner on brand-new conversations.
hasMoreRef.current = false;
setHasMore(false);
- throw new Error(
- "Unable to load older events because the oldest loaded event has no timestamp.",
- );
+ return;
}
isLoadingRef.current = true;
@@ -111,6 +157,7 @@ export const useLoadOlderEvents = (
}
}, [
conversationId,
+ conversation,
conversation?.conversation_url,
conversation?.session_api_key,
addEvents,
diff --git a/src/i18n/translation.json b/src/i18n/translation.json
index 3cd3122bc..fd41b3232 100644
--- a/src/i18n/translation.json
+++ b/src/i18n/translation.json
@@ -10234,7 +10234,7 @@
"ca": "Envia el missatge"
},
"CHAT_INTERFACE$UPLOAD_IMAGES_AS_FILES": {
- "en": "upload as file",
+ "en": "Upload as file",
"ja": "ファイルとしてアップロード",
"zh-CN": "作为文件上传",
"zh-TW": "作為檔案上傳",
@@ -10250,6 +10250,23 @@
"tr": "dosya olarak yükle",
"uk": "завантажити як файл"
},
+ "CHAT_INTERFACE$DO_NOT_UPLOAD_AS_FILE": {
+ "en": "Do not upload as file",
+ "ja": "ファイルとしてアップロードしない",
+ "zh-CN": "不作为文件上传",
+ "zh-TW": "不作為檔案上傳",
+ "ko-KR": "파일로 업로드하지 않음",
+ "no": "ikke last opp som fil",
+ "ar": "عدم التحميل كملف",
+ "de": "Nicht als Datei hochladen",
+ "fr": "ne pas envoyer en tant que fichier",
+ "it": "non caricare come file",
+ "pt": "não enviar como arquivo",
+ "es": "no subir como archivo",
+ "ca": "no pujar com a fitxer",
+ "tr": "dosya olarak yükleme",
+ "uk": "не завантажувати як файл"
+ },
"CHAT_INTERFACE$TOOLTIP_UPLOAD_IMAGE": {
"en": "Upload image",
"zh-CN": "上传图片",
@@ -10284,6 +10301,40 @@
"uk": "Додати файл",
"ca": "Afegeix un fitxer"
},
+ "CHAT_INTERFACE$ADD_FILES_AND_IMAGES": {
+ "en": "Add Files and Images",
+ "ja": "ファイルと画像を追加",
+ "zh-CN": "添加文件和图片",
+ "zh-TW": "新增檔案與圖片",
+ "ko-KR": "파일 및 이미지 추가",
+ "no": "Legg til filer og bilder",
+ "it": "Aggiungi file e immagini",
+ "pt": "Adicionar arquivos e imagens",
+ "es": "Añadir archivos e imágenes",
+ "ar": "إضافة ملفات وصور",
+ "fr": "Ajouter des fichiers et des images",
+ "tr": "Dosya ve görsel ekle",
+ "de": "Dateien und Bilder hinzufügen",
+ "uk": "Додати файли та зображення",
+ "ca": "Afegeix fitxers i imatges"
+ },
+ "CHAT_INTERFACE$PLUS_MENU": {
+ "en": "More actions",
+ "ja": "その他の操作",
+ "zh-CN": "更多操作",
+ "zh-TW": "更多操作",
+ "ko-KR": "추가 작업",
+ "no": "Flere handlinger",
+ "it": "Altre azioni",
+ "pt": "Mais ações",
+ "es": "Más acciones",
+ "ar": "المزيد من الإجراءات",
+ "fr": "Plus d'actions",
+ "tr": "Diğer işlemler",
+ "de": "Weitere Aktionen",
+ "uk": "Більше дій",
+ "ca": "Més accions"
+ },
"CHAT_INTERFACE$INITIAL_MESSAGE": {
"en": "Hi! I'm OpenHands, an AI Software Engineer. What would you like to build with me today?",
"zh-CN": "你好!我是 OpenHands,一名 AI 软件工程师。今天想和我一起编写什么程序呢?",
diff --git a/src/stores/conversation-store.ts b/src/stores/conversation-store.ts
index c7f379d8b..8e3b75ee6 100644
--- a/src/stores/conversation-store.ts
+++ b/src/stores/conversation-store.ts
@@ -25,7 +25,10 @@ interface ConversationState {
selectedTab: ConversationTab | null;
images: File[];
files: File[];
- uploadImagesAsFiles: boolean; // If true, attached images are sent through the file-upload path instead of being embedded in the LLM message
+ /** Image file names (e.g. pasted screenshots) to send via file upload instead of vision embed. */
+ imagesMarkedUploadAsFile: string[];
+ /** Image file names attached in chat (controls per-image upload-as-file UI). */
+ pastedImageNames: string[];
loadingFiles: string[]; // File names currently being processed
loadingImages: string[]; // Image names currently being processed
messageToSend: IMessageToSend | null;
@@ -45,7 +48,8 @@ interface ConversationActions {
setShouldHideSuggestions: (shouldHideSuggestions: boolean) => void;
addImages: (images: File[]) => void;
addFiles: (files: File[]) => void;
- setUploadImagesAsFiles: (uploadImagesAsFiles: boolean) => void;
+ toggleImageUploadAsFile: (fileName: string) => void;
+ markImagesAsPasted: (fileNames: string[]) => void;
removeImage: (index: number) => void;
removeFile: (index: number) => void;
clearImages: () => void;
@@ -108,7 +112,8 @@ export const useConversationStore = create()(
selectedTab: "files" as ConversationTab,
images: [],
files: [],
- uploadImagesAsFiles: false,
+ imagesMarkedUploadAsFile: [],
+ pastedImageNames: [],
loadingFiles: [],
loadingImages: [],
messageToSend: null,
@@ -147,15 +152,48 @@ export const useConversationStore = create()(
"addFiles",
),
- setUploadImagesAsFiles: (uploadImagesAsFiles) =>
- set({ uploadImagesAsFiles }, false, "setUploadImagesAsFiles"),
+ toggleImageUploadAsFile: (fileName) =>
+ set(
+ (state) => {
+ const marked = new Set(state.imagesMarkedUploadAsFile);
+ if (marked.has(fileName)) {
+ marked.delete(fileName);
+ } else {
+ marked.add(fileName);
+ }
+ return { imagesMarkedUploadAsFile: [...marked] };
+ },
+ false,
+ "toggleImageUploadAsFile",
+ ),
+
+ markImagesAsPasted: (fileNames) =>
+ set(
+ (state) => {
+ const merged = new Set([...state.pastedImageNames, ...fileNames]);
+ return { pastedImageNames: [...merged] };
+ },
+ false,
+ "markImagesAsPasted",
+ ),
removeImage: (index) =>
set(
(state) => {
+ const removed = state.images[index];
const newImages = [...state.images];
newImages.splice(index, 1);
- return { images: newImages };
+ return {
+ images: newImages,
+ imagesMarkedUploadAsFile: removed
+ ? state.imagesMarkedUploadAsFile.filter(
+ (name) => name !== removed.name,
+ )
+ : state.imagesMarkedUploadAsFile,
+ pastedImageNames: removed
+ ? state.pastedImageNames.filter((name) => name !== removed.name)
+ : state.pastedImageNames,
+ };
},
false,
"removeImage",
@@ -181,7 +219,8 @@ export const useConversationStore = create()(
{
images: [],
files: [],
- uploadImagesAsFiles: false,
+ imagesMarkedUploadAsFile: [],
+ pastedImageNames: [],
loadingFiles: [],
loadingImages: [],
},
diff --git a/src/stores/optimistic-user-message-store.ts b/src/stores/optimistic-user-message-store.ts
index a8412a083..69490d617 100644
--- a/src/stores/optimistic-user-message-store.ts
+++ b/src/stores/optimistic-user-message-store.ts
@@ -84,6 +84,14 @@ interface OptimisticUserMessageActions {
) => PendingUserMessage | null;
/** Wipe all queued messages (e.g., when changing conversations). */
clearPendingMessages: () => void;
+ /**
+ * Move pending entries from a provisional task URL (`task-{uuid}`) to the
+ * real conversation id once cloud provisioning finishes.
+ */
+ reassignPendingMessages: (
+ fromConversationId: string,
+ toConversationId: string,
+ ) => void;
}
type OptimisticUserMessageStore = OptimisticUserMessageState &
@@ -190,5 +198,14 @@ export const useOptimisticUserMessageStore = create(
},
clearPendingMessages: () => set(() => ({ ...initialState })),
+
+ reassignPendingMessages: (fromConversationId, toConversationId) =>
+ set((state) => ({
+ pendingMessages: state.pendingMessages.map((message) =>
+ message.conversationId === fromConversationId
+ ? { ...message, conversationId: toConversationId }
+ : message,
+ ),
+ })),
}),
);
diff --git a/src/stores/pending-task-attachments-store.ts b/src/stores/pending-task-attachments-store.ts
new file mode 100644
index 000000000..3fa0b618e
--- /dev/null
+++ b/src/stores/pending-task-attachments-store.ts
@@ -0,0 +1,60 @@
+import { create } from "zustand";
+
+export interface PendingTaskAttachments {
+ content: string;
+ images: File[];
+ files: File[];
+ imagesMarkedUploadAsFile: string[];
+}
+
+interface PendingTaskAttachmentsState {
+ byTaskId: Record;
+ setPendingTaskAttachments: (
+ taskId: string,
+ payload: PendingTaskAttachments,
+ ) => void;
+ consumePendingTaskAttachments: (
+ taskId: string,
+ ) => PendingTaskAttachments | null;
+}
+
+export const usePendingTaskAttachmentsStore =
+ create()((set, get) => ({
+ byTaskId: {},
+
+ setPendingTaskAttachments: (taskId, payload) =>
+ set((state) => ({
+ byTaskId: { ...state.byTaskId, [taskId]: payload },
+ })),
+
+ consumePendingTaskAttachments: (taskId) => {
+ const payload = get().byTaskId[taskId];
+ if (!payload) {
+ return null;
+ }
+
+ set((state) => {
+ const { [taskId]: _removed, ...rest } = state.byTaskId;
+ return { byTaskId: rest };
+ });
+
+ return payload;
+ },
+ }));
+
+export function setPendingTaskAttachments(
+ taskId: string,
+ payload: PendingTaskAttachments,
+): void {
+ usePendingTaskAttachmentsStore
+ .getState()
+ .setPendingTaskAttachments(taskId, payload);
+}
+
+export function consumePendingTaskAttachments(
+ taskId: string,
+): PendingTaskAttachments | null {
+ return usePendingTaskAttachmentsStore
+ .getState()
+ .consumePendingTaskAttachments(taskId);
+}
diff --git a/src/utils/enqueue-home-task-pending-message.ts b/src/utils/enqueue-home-task-pending-message.ts
new file mode 100644
index 000000000..e576d91ea
--- /dev/null
+++ b/src/utils/enqueue-home-task-pending-message.ts
@@ -0,0 +1,30 @@
+import { partitionImagesForUpload } from "#/components/features/chat/utils/chat-input.utils";
+import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
+import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
+
+/**
+ * Shows the user's first message immediately on cloud start-task routes
+ * (`/conversations/task-{uuid}`) while the sandbox provisions.
+ */
+export async function enqueueHomeTaskPendingMessage(options: {
+ conversationId: string;
+ text: string;
+ images: File[];
+ imagesMarkedUploadAsFile: string[];
+}): Promise {
+ const { imagesToEmbed } = partitionImagesForUpload(
+ options.images,
+ options.imagesMarkedUploadAsFile,
+ );
+ const imageUrls = await Promise.all(
+ imagesToEmbed.map((image) => convertImageToBase64(image)),
+ );
+
+ useOptimisticUserMessageStore.getState().enqueuePendingMessage({
+ conversationId: options.conversationId,
+ text: options.text,
+ content: options.text,
+ imageUrls,
+ fileUrls: [],
+ });
+}
diff --git a/src/utils/flush-pending-task-attachments.ts b/src/utils/flush-pending-task-attachments.ts
new file mode 100644
index 000000000..f00357cd2
--- /dev/null
+++ b/src/utils/flush-pending-task-attachments.ts
@@ -0,0 +1,33 @@
+import i18n, { OPENHANDS_I18N_NAMESPACE, waitForI18n } from "#/i18n";
+import { consumePendingTaskAttachments } from "#/stores/pending-task-attachments-store";
+import { displayErrorToast } from "#/utils/custom-toast-handlers";
+import { sendMessageWithAttachments } from "#/utils/send-message-with-attachments";
+
+/**
+ * Sends attachments queued during cloud start-task provisioning once the
+ * real conversation UUID is available.
+ */
+export async function flushPendingTaskAttachments(
+ taskId: string,
+ conversationId: string,
+): Promise {
+ const pending = consumePendingTaskAttachments(taskId);
+ if (!pending) {
+ return;
+ }
+
+ try {
+ await waitForI18n();
+ await sendMessageWithAttachments({
+ conversationId,
+ content: pending.content,
+ images: pending.images,
+ files: pending.files,
+ imagesMarkedUploadAsFile: pending.imagesMarkedUploadAsFile,
+ t: i18n.getFixedT(null, OPENHANDS_I18N_NAMESPACE),
+ });
+ } catch (error) {
+ displayErrorToast(error instanceof Error ? error.message : null);
+ throw error;
+ }
+}
diff --git a/src/utils/pending-task-message-link.ts b/src/utils/pending-task-message-link.ts
new file mode 100644
index 000000000..62f4c7da0
--- /dev/null
+++ b/src/utils/pending-task-message-link.ts
@@ -0,0 +1,61 @@
+/**
+ * While a cloud start task redirects from `/conversations/task-{uuid}` to the
+ * real conversation id, optimistic pending messages stay keyed to the task id
+ * until a layout effect can reassign them. This map lets the chat UI keep
+ * matching those bubbles against the real conversation route.
+ */
+const taskSourceByRealConversationId = new Map();
+
+let scheduledPendingReassign: {
+ fromConversationId: string;
+ toConversationId: string;
+} | null = null;
+
+export function linkPendingTaskMessages(
+ realConversationId: string,
+ taskConversationId: string,
+): void {
+ taskSourceByRealConversationId.set(realConversationId, taskConversationId);
+}
+
+export function clearPendingTaskMessageLink(realConversationId: string): void {
+ taskSourceByRealConversationId.delete(realConversationId);
+}
+
+export function schedulePendingTaskMessageReassign(
+ fromConversationId: string,
+ toConversationId: string,
+): void {
+ scheduledPendingReassign = { fromConversationId, toConversationId };
+}
+
+export function consumeScheduledPendingTaskMessageReassign(
+ conversationId: string,
+): { fromConversationId: string; toConversationId: string } | null {
+ if (scheduledPendingReassign?.toConversationId !== conversationId) {
+ return null;
+ }
+
+ const value = scheduledPendingReassign;
+ scheduledPendingReassign = null;
+ return value;
+}
+
+export function matchesPendingConversationId(
+ activeConversationId: string,
+ pendingConversationId: string,
+): boolean {
+ if (pendingConversationId === activeConversationId) {
+ return true;
+ }
+
+ const linkedTaskConversationId =
+ taskSourceByRealConversationId.get(activeConversationId);
+ return linkedTaskConversationId === pendingConversationId;
+}
+
+/** Test helper */
+export function resetPendingTaskMessageLinkState(): void {
+ taskSourceByRealConversationId.clear();
+ scheduledPendingReassign = null;
+}
diff --git a/src/utils/send-message-with-attachments.ts b/src/utils/send-message-with-attachments.ts
new file mode 100644
index 000000000..b57dd97bf
--- /dev/null
+++ b/src/utils/send-message-with-attachments.ts
@@ -0,0 +1,93 @@
+import type { TFunction } from "i18next";
+import {
+ resolveConversationRuntime,
+ uploadFilesToConversation,
+} from "#/api/conversation-file-upload.api";
+import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
+import type { SendMessageRequest } from "#/api/conversation-service/agent-server-conversation-service.types";
+import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
+import { displayErrorToast } from "#/utils/custom-toast-handlers";
+import { partitionImagesForUpload } from "#/components/features/chat/utils/chat-input.utils";
+import { validateFiles } from "#/utils/file-validation";
+
+export interface SendMessageWithAttachmentsResult {
+ text: string;
+ content: string;
+ imageUrls: string[];
+ fileUrls: string[];
+ timestamp: string;
+}
+
+export async function sendMessageWithAttachments(options: {
+ conversationId: string;
+ content: string;
+ images: File[];
+ files: File[];
+ imagesMarkedUploadAsFile: string[];
+ t: TFunction;
+}): Promise {
+ const {
+ conversationId,
+ content,
+ images,
+ files,
+ imagesMarkedUploadAsFile,
+ t,
+ } = options;
+
+ const { imagesToEmbed, imagesAsFiles } = partitionImagesForUpload(
+ images,
+ imagesMarkedUploadAsFile,
+ );
+ const filesToUpload = [...files, ...imagesAsFiles];
+
+ const validation = validateFiles([...imagesToEmbed, ...filesToUpload]);
+ if (!validation.isValid) {
+ throw new Error(validation.errorMessage ?? "Invalid attachments");
+ }
+
+ const imageUrls = await Promise.all(
+ imagesToEmbed.map((image) => convertImageToBase64(image)),
+ );
+
+ const runtime = await resolveConversationRuntime(conversationId);
+
+ const { skipped_files: skippedFiles, uploaded_files: uploadedFiles } =
+ filesToUpload.length > 0
+ ? await uploadFilesToConversation(conversationId, filesToUpload)
+ : { skipped_files: [], uploaded_files: [] };
+
+ skippedFiles.forEach((file) => displayErrorToast(file.reason));
+
+ const filePrompt = `${t("CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE")}: ${uploadedFiles.join("\n\n")}`;
+ const prompt =
+ uploadedFiles.length > 0 ? `${content}\n\n${filePrompt}` : content;
+
+ const timestamp = new Date().toISOString();
+
+ const messageContent: SendMessageRequest = {
+ role: "user",
+ content: [{ type: "text", text: prompt }],
+ };
+
+ if (imageUrls.length > 0) {
+ messageContent.content.push({
+ type: "image",
+ image_urls: imageUrls,
+ });
+ }
+
+ await AgentServerConversationService.sendMessage(
+ conversationId,
+ messageContent,
+ runtime,
+ );
+
+ return {
+ text: content,
+ content: prompt,
+ imageUrls,
+ fileUrls: uploadedFiles,
+ timestamp,
+ };
+}