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
70 changes: 17 additions & 53 deletions src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import { ImageAttachments, type ImageAttachment } from "../ImageAttachments";
import {
extractImagesFromClipboard,
extractImagesFromDrop,
imageAttachmentsToImageParts,
processImageFiles,
} from "@/browser/utils/imageHandling";

Expand Down Expand Up @@ -241,10 +242,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
() => createTokenCountResource(tokenCountPromise),
[tokenCountPromise]
);
const hasTypedText = input.trim().length > 0;
const hasImages = imageAttachments.length > 0;
const hasReviews = attachedReviews.length > 0;
const canSend = (hasTypedText || hasImages || hasReviews) && !disabled && !isSending;

// Setter for model - updates localStorage directly so useSendMessageOptions picks it up
const setPreferredModel = useCallback(
(model: string) => {
Expand Down Expand Up @@ -272,6 +270,12 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
}
);

const isSendInFlight = variant === "creation" ? creationState.isSending : isSending;
const hasTypedText = input.trim().length > 0;
const hasImages = imageAttachments.length > 0;
const hasReviews = attachedReviews.length > 0;
const canSend = (hasTypedText || hasImages || hasReviews) && !disabled && !isSendInFlight;

// When entering creation mode, initialize the project-scoped model to the
// default so previous manual picks don't bleed into new creation flows.
// Only runs once per creation session (not when defaultModel changes, which
Expand Down Expand Up @@ -640,12 +644,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
// Route to creation handler for creation variant
if (variant === "creation") {
// Creation variant: simple message send + workspace creation
setIsSending(true);
// Convert image attachments to image parts
const creationImageParts = imageAttachments.map((img) => ({
url: img.url,
mediaType: img.mediaType,
}));
const creationImageParts = imageAttachmentsToImageParts(imageAttachments);
const ok = await creationState.handleSend(
messageText,
creationImageParts.length > 0 ? creationImageParts : undefined
Expand All @@ -659,7 +658,6 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
inputRef.current.style.height = "";
}
}
setIsSending(false);
return;
}

Expand Down Expand Up @@ -1066,33 +1064,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {

try {
// Prepare image parts if any
const imageParts = imageAttachments.map((img, index) => {
// Validate before sending to help with debugging
if (!img.url || typeof img.url !== "string") {
console.error(
`Image attachment [${index}] has invalid url:`,
typeof img.url,
img.url?.slice(0, 50)
);
}
if (!img.url?.startsWith("data:")) {
console.error(
`Image attachment [${index}] url is not a data URL:`,
img.url?.slice(0, 100)
);
}
if (!img.mediaType || typeof img.mediaType !== "string") {
console.error(
`Image attachment [${index}] has invalid mediaType:`,
typeof img.mediaType,
img.mediaType
);
}
return {
url: img.url,
mediaType: img.mediaType,
};
});
const imageParts = imageAttachmentsToImageParts(imageAttachments, { validate: true });

// When editing a /compact command, regenerate the actual summarization request
let actualMessageText = messageText;
Expand Down Expand Up @@ -1308,7 +1280,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
const placeholder = (() => {
// Creation variant has simple placeholder
if (variant === "creation") {
return `Type your first message to create a workspace... (${formatKeybind(KEYBINDS.SEND_MESSAGE)} to send, Esc to cancel)`;
return `Type your first message to create a workspace... (${formatKeybind(KEYBINDS.SEND_MESSAGE)} to send, ${formatKeybind(KEYBINDS.CANCEL)} to cancel)`;
}

// Workspace variant placeholders
Expand Down Expand Up @@ -1353,17 +1325,9 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
{variant === "creation" && (
<CreationCenterContent
projectName={props.projectName}
isSending={creationState.isSending || isSending}
workspaceName={
creationState.isSending || isSending
? creationState.creatingWithIdentity?.name
: undefined
}
workspaceTitle={
creationState.isSending || isSending
? creationState.creatingWithIdentity?.title
: undefined
}
isSending={isSendInFlight}
workspaceName={isSendInFlight ? creationState.creatingWithIdentity?.name : undefined}
workspaceTitle={isSendInFlight ? creationState.creatingWithIdentity?.title : undefined}
/>
)}

Expand Down Expand Up @@ -1444,7 +1408,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
onRuntimeModeChange={creationState.setRuntimeMode}
onSetDefaultRuntime={creationState.setDefaultRuntimeMode}
onSshHostChange={creationState.setSshHost}
disabled={creationState.isSending || isSending}
disabled={isSendInFlight}
projectName={props.projectName}
nameState={creationState.nameState}
/>
Expand Down Expand Up @@ -1486,7 +1450,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
onEscapeInNormalMode={handleEscapeInNormalMode}
suppressKeys={showCommandSuggestions ? COMMAND_SUGGESTION_KEYS : undefined}
placeholder={placeholder}
disabled={!editingMessage && (disabled || isSending)}
disabled={!editingMessage && (disabled || isSendInFlight)}
aria-label={editingMessage ? "Edit your last message" : "Message Claude"}
aria-autocomplete="list"
aria-controls={
Expand All @@ -1505,7 +1469,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
shouldShowUI={voiceInput.shouldShowUI}
requiresSecureContext={voiceInput.requiresSecureContext}
onToggle={voiceInput.toggle}
disabled={disabled || isSending}
disabled={disabled || isSendInFlight}
mode={mode}
/>
</div>
Expand Down
41 changes: 41 additions & 0 deletions src/browser/utils/imageHandling.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ImagePart } from "@/common/orpc/types";
import type { ImageAttachment } from "@/browser/components/ImageAttachments";

/**
Expand All @@ -24,6 +25,46 @@ function getMimeTypeFromExtension(filename: string): string {
return mimeTypes[ext ?? ""] ?? "image/png";
}

/**
* Convert ImageAttachment[] → ImagePart[] for API calls.
*
* Kept in imageHandling to ensure creation + send flows stay aligned.
*/
export function imageAttachmentsToImageParts(
attachments: ImageAttachment[],
options?: { validate?: boolean }
): ImagePart[] {
const validate = options?.validate ?? false;

return attachments.map((img, index) => {
if (validate) {
// Validate before sending to help with debugging
if (!img.url || typeof img.url !== "string") {
console.error(
`Image attachment [${index}] has invalid url:`,
typeof img.url,
(img as { url?: unknown }).url
);
}
if (typeof img.url === "string" && !img.url.startsWith("data:")) {
console.error(`Image attachment [${index}] url is not a data URL:`, img.url.slice(0, 100));
}
if (!img.mediaType || typeof img.mediaType !== "string") {
console.error(
`Image attachment [${index}] has invalid mediaType:`,
typeof img.mediaType,
(img as { mediaType?: unknown }).mediaType
);
}
}

return {
url: img.url,
mediaType: img.mediaType,
};
});
}

/**
* Converts a File to an ImageAttachment with a base64 data URL
*/
Expand Down